This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
import { Suspense } from "react"
import { LoginPage } from "@/components/auth/login-page"
import { Suspense } from 'react';
import { LoginPage } from '@/components/auth/login-page';
export default function Page() {
return (
<Suspense>
<LoginPage />
</Suspense>
)
);
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {Suspense} from "react"
import {RegisterPage} from "@/components/auth/register-page"
import { Suspense } from 'react';
import { RegisterPage } from '@/components/auth/register-page';
export default function Page() {
return (
<Suspense>
<RegisterPage />
</Suspense>
)
);
}
+7 -5
View File
@@ -1,15 +1,17 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
import {apiSections, DOCS_LAST_UPDATED} from "@/components/common/docs/api";
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function ApiDocPage() {
return (
<LegalPageLayout
title="API 接口文档"
title='API 接口文档'
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
Wavelet 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。
<p className='text-muted-foreground text-sm leading-relaxed'>
Wavelet 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
</p>
}
/>
+7 -6
View File
@@ -1,16 +1,17 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
import {howToUseSections} from "@/components/common/docs/how-to-use";
import {DOCS_LAST_UPDATED} from "@/components/common/docs/api";
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
return (
<LegalPageLayout
title="平台使用指南"
title='平台使用指南'
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
欢迎使用 Wavelet 脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 Wavelet
脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
</p>
}
/>
@@ -1,20 +1,20 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"
import { privacySections } from "@/components/common/docs/privacy"
import { TERMS_LAST_UPDATED } from "@/components/common/docs/terms"
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
return (
<LegalPageLayout
title="隐私政策 (Privacy)"
title='隐私政策 (Privacy)'
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
<br className="hidden md:block" />
<br className='hidden md:block' />
以及您享有的相关权利。
</span>
}
/>
)
);
}
@@ -1,17 +1,16 @@
import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"
import { termsSections, TERMS_LAST_UPDATED } from "@/components/common/docs/terms"
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
return (
<LegalPageLayout
title="服务协议 (Terms)"
title='服务协议 (Terms)'
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={
<span>
为了保障您的合法权益,请您仔细阅读以下条款。
</span>
}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
/>
)
);
}
@@ -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>
)
);
}
+226 -188
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import dynamic from "next/dynamic"
import * as React from "react"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import dynamic from 'next/dynamic';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
Activity,
Cpu,
@@ -15,134 +15,142 @@ import {
RefreshCw,
Server,
Terminal,
} from "lucide-react"
} 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"
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" />
)
<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),
() => import('./components/table-browser').then((mod) => mod.TableBrowser),
{ loading: () => sectionFallback },
)
);
const CacheManager = dynamic(
() => import("./components/cache-manager").then((mod) => mod.CacheManager),
() => import('./components/cache-manager').then((mod) => mod.CacheManager),
{ loading: () => sectionFallback },
)
);
const SQLConsole = dynamic(
() => import("./components/sql-console").then((mod) => mod.SQLConsole),
() => 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, ",")
}
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export function DatabasePageClient() {
// 核心状态
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 [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)
const [showConsole, setShowConsole] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState<number>(0);
// 1. 获取运行概览
const fetchOverview = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true)
if (!isSilent) setLoadingOverview(true);
try {
const data = await services.dbManage.getOverview()
setOverview(data)
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error("获取数据库概览失败", {
description: err instanceof Error ? err.message : "未知错误",
})
toast.error('获取数据库概览失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingOverview(false)
setLoadingOverview(false);
}
}, [])
}, []);
// 2. 获取表列表
const fetchTables = useCallback(async () => {
setLoadingTables(true)
setLoadingTables(true);
try {
const data = await services.dbManage.listTables()
setTables(data)
const data = await services.dbManage.listTables();
setTables(data);
} catch (err) {
toast.error("获取数据库数据表列表失败", {
description: err instanceof Error ? err.message : "未知错误",
})
toast.error('获取数据库数据表列表失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingTables(false)
setLoadingTables(false);
}
}, [])
}, []);
// 3. 协调刷新
const handleRefreshAll = () => {
fetchOverview()
fetchTables()
setRefreshTrigger((prev) => prev + 1)
}
fetchOverview();
fetchTables();
setRefreshTrigger((prev) => prev + 1);
};
// 4. 导出数据库备份
const handleExport = async () => {
setExporting(true)
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("数据库导出成功", { description: `已下载归档文件: ${filename}` })
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('数据库导出成功', {
description: `已下载归档文件: ${filename}`,
});
} catch (err) {
toast.error("数据库导出失败", {
description: err instanceof Error ? err.message : "导出异常",
})
toast.error('数据库导出失败', {
description: err instanceof Error ? err.message : '导出异常',
});
} finally {
setExporting(false)
setExporting(false);
}
}
};
// 初始化加载
useEffect(() => {
fetchOverview()
fetchTables()
}, [fetchOverview, fetchTables])
fetchOverview();
fetchTables();
}, [fetchOverview, fetchTables]);
// 渲染概览卡片骨架
const renderOverviewSkeleton = () => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<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" />
<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" />
<Skeleton className='h-6 w-24' />
</CardContent>
</Card>
))}
</div>
)
);
// 渲染 SQL 查询控台全屏视图
if (showConsole) {
@@ -151,110 +159,136 @@ export function DatabasePageClient() {
dbType={overview?.type}
onClose={() => setShowConsole(false)}
/>
)
);
}
// 正常页面排版布局
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 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 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">数据管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>数据管理</h1>
</div>
</div>
<Button
size="sm"
variant="secondary"
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
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" : ""}`} />
<RefreshCw
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
/>
刷新数据
</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">数据库类型</CardDescription>
<Server className="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-bold uppercase">{overview.type}</div>
</CardContent>
</Card>
{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'>
数据库类型
</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">版本信息</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>
{/* 卡片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'>
版本信息
</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">名称/路径</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>
{/* 卡片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'>
名称/路径
</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">数据库大小</CardDescription>
<HardDrive className="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-bold">{overview.size}</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'>
数据库大小
</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">物理数据表</CardDescription>
<Layers className="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-bold">{formatNumber(overview.table_count)}</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'>
物理数据表
</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">活跃连接数</CardDescription>
<Activity className="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm font-bold">{formatNumber(overview.connections)}</div>
</CardContent>
</Card>
</div>
)
)}
{/* 卡片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'>
活跃连接数
</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
@@ -264,60 +298,64 @@ export function DatabasePageClient() {
/>
{/* 3. 缓存管理区块 */}
<CacheManager
refreshTrigger={refreshTrigger}
/>
<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">功能区</CardTitle>
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
<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>
<CardDescription className='text-[11px]'>
数据库导出及自定义高级 SQL 执行终端
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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" />
<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' />
数据库备份导出
</p>
<p className="text-[10px] text-muted-foreground">
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件)
<p className='text-[10px] text-muted-foreground'>
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
导出为打包的 .sql 文本文件)
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1"
size='sm'
variant='outline'
className='h-8 text-xs gap-1'
onClick={handleExport}
disabled={exporting}
>
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
{exporting ? "正在准备..." : "开始导出"}
<RefreshCw
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
/>
{exporting ? '正在准备...' : '开始导出'}
</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" />
<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' />
SQL 查询控台
</p>
<p className="text-[10px] text-muted-foreground">
打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化
<p className='text-[10px] text-muted-foreground'>
打开在线 SQL 控制终端,执行原生的 SQL
进行数据筛选、更新、调试或性能优化
</p>
</div>
<Button
size="sm"
className="h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95"
size='sm'
className='h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95'
onClick={() => {
setShowConsole(true)
setShowConsole(true);
}}
>
<FileText className="size-3.5" />
<FileText className='size-3.5' />
进入控台
</Button>
</div>
@@ -325,5 +363,5 @@ export function DatabasePageClient() {
</CardContent>
</Card>
</div>
)
}
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import {DatabasePageClient} from "./page-client"
import { DatabasePageClient } from './page-client';
export default function DatabasePage() {
return <DatabasePageClient />
}
return <DatabasePageClient />;
}
+131 -37
View File
@@ -1,82 +1,176 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {Code} from "lucide-react"
import * as React from 'react';
import { Code } from 'lucide-react';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// 引入模块化拆分后的 Tab 子组件
import {DashboardTab} from "./tabs/dashboard-tab"
import {TableTab} from "./tabs/table-tab"
import {ControlsTab} from "./tabs/controls-tab"
import { DashboardTab } from './tabs/dashboard-tab';
import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const [activeTab, setActiveTab] = React.useState("dashboard")
const [activeTab, setActiveTab] = React.useState('dashboard');
return (
<div className="py-6 px-1 space-y-6">
<div className='py-6 px-1 space-y-6'>
{/* 1. 标准页面标题 */}
<div className="flex items-center gap-2">
<Code className="size-5 text-primary" />
<div className='flex items-center gap-2'>
<Code className='size-5 text-primary' />
<div>
<h1 className="text-2xl font-semibold tracking-tight">开发规范示例</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
开发规范示例
</h1>
</div>
</div>
{/* 说明区域 */}
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Code className="size-4 text-primary" />
<CardTitle className="text-base font-semibold">规范说明</CardTitle>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-center gap-2'>
<Code className='size-4 text-primary' />
<CardTitle className='text-base font-semibold'>规范说明</CardTitle>
</div>
<CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">核心规范细节与编写原则:</h3>
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li><strong>结构专注</strong>:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。</li>
<li><strong>容器与上边距对齐</strong>:使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-center gap-2</code> 作为标题行基础容器(不含下边框),外层统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6 px-1</code> (或 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6</code>) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。</li>
<li><strong>图标呈现</strong>:直接将 Lucide 图标组件嵌套于标题容器中,应用 <code className="bg-muted px-1 py-0.5 rounded font-mono">size-5 text-primary</code> 样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。</li>
<li><strong>文本样式标准</strong>:文本使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'h1 className="text-2xl font-semibold tracking-tight"'}</code>,禁用字重加粗(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">font-bold</code>)或任何渐变色,保证全站字体一致性。</li>
<li><strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab 页切换的页面,**禁止**将所有 Tab 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 Content 必须单独拆分为独立组件文件(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">tabs/events-tab.tsx</code> 或就近放在 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/</code> 下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。</li>
<li><strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs 切换,当一个页面代码行数超过 600 行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">app/(main)/admin/database/components/</code>),只有真正跨页面复用的组件才进入全局 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/common/</code>。典型标杆案例参考“数据管理” (<code className="bg-muted px-1 py-0.5 rounded font-mono">/admin/database</code>) 的 <code className="bg-muted px-1 py-0.5 rounded font-mono">table-browser.tsx</code>、<code className="bg-muted px-1 py-0.5 rounded font-mono">cache-manager.tsx</code> 与 <code className="bg-muted px-1 py-0.5 rounded font-mono">sql-console.tsx</code>。</li>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
核心规范细节与编写原则:
</h3>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
<strong>结构专注</strong>
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
</li>
<li>
<strong>容器与上边距对齐</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
作为标题行基础容器(不含下边框),外层统一使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
(或{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
</li>
<li>
<strong>图标呈现</strong>:直接将 Lucide
图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
</li>
<li>
<strong>文本样式标准</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
,禁用字重加粗(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-bold
</code>
)或任何渐变色,保证全站字体一致性。
</li>
<li>
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab
页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
或就近放在{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/
</code>{' '}
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
</li>
<li>
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs
切换,当一个页面代码行数超过 600
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
</code>
),只有真正跨页面复用的组件才进入全局{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/common/
</code>
。典型标杆案例参考“数据管理” (
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
) 的{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
table-browser.tsx
</code>
、
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
cache-manager.tsx
</code>{' '}
与{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
sql-console.tsx
</code>
。
</li>
</ul>
</div>
</CardContent>
</Card>
{/* Tabs 切换 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="dashboard" className="px-0 pb-2 text-xs font-semibold">
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='dashboard'
className='px-0 pb-2 text-xs font-semibold'
>
仪表盘与指标卡片
</TabsTrigger>
<TabsTrigger value="table" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='table'
className='px-0 pb-2 text-xs font-semibold'
>
数据表格规范
</TabsTrigger>
<TabsTrigger value="controls" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='controls'
className='px-0 pb-2 text-xs font-semibold'
>
输入控件与表单
</TabsTrigger>
</TabsList>
<TabsContent value="dashboard" className="focus-visible:outline-none">
<TabsContent value='dashboard' className='focus-visible:outline-none'>
<DashboardTab />
</TabsContent>
<TabsContent value="table" className="focus-visible:outline-none">
<TabsContent value='table' className='focus-visible:outline-none'>
<TableTab />
</TabsContent>
<TabsContent value="controls" className="focus-visible:outline-none">
<TabsContent value='controls' className='focus-visible:outline-none'>
<ControlsTab />
</TabsContent>
</Tabs>
</div>
)
);
}
@@ -1,100 +1,148 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {Code} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Label} from "@/components/ui/label"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Checkbox} from "@/components/ui/checkbox"
import {Button} from "@/components/ui/button"
import * as React from 'react';
import { Code } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const [formData, setFormData] = React.useState({
name: "",
category: "database",
name: '',
category: 'database',
allowNotify: true,
channel: "all",
})
channel: 'all',
});
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{/* 控件展示 */}
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">表单与输入框交互演示</CardTitle>
<CardDescription>包含按钮变体、下拉选择、多选框及基础输入框的标准样式</CardDescription>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold'>
表单与输入框交互演示
</CardTitle>
<CardDescription>
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className='space-y-4'>
{/* 输入框 */}
<div className="space-y-1.5">
<Label htmlFor="task-name" className="text-xs font-medium text-foreground">任务名称</Label>
<div className='space-y-1.5'>
<Label
htmlFor='task-name'
className='text-xs font-medium text-foreground'
>
任务名称
</Label>
<Input
id="task-name"
id='task-name'
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="输入要创建的任务描述..."
className="h-8 text-xs shadow-none bg-background focus-visible:ring-1"
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='输入要创建的任务描述...'
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
/>
<p className="text-[10px] text-muted-foreground">任务名称将用于后台执行记录的可读性显示</p>
<p className='text-[10px] text-muted-foreground'>
任务名称将用于后台执行记录的可读性显示
</p>
</div>
{/* 下拉框 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">任务分类</Label>
<div className='space-y-1.5'>
<Label className='text-xs font-medium text-foreground'>
任务分类
</Label>
<Select
value={formData.category}
onValueChange={(val) => setFormData(prev => ({ ...prev, category: val }))}
onValueChange={(val) =>
setFormData((prev) => ({ ...prev, category: val }))
}
>
<SelectTrigger className="h-8 text-xs shadow-none bg-background focus:ring-1">
<SelectTrigger className='h-8 text-xs shadow-none bg-background focus:ring-1'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="database">数据库运维</SelectItem>
<SelectItem value="sync">数据批量同步</SelectItem>
<SelectItem value="cleanup">过期磁盘清理</SelectItem>
<SelectItem value='database'>数据库运维</SelectItem>
<SelectItem value='sync'>数据批量同步</SelectItem>
<SelectItem value='cleanup'>过期磁盘清理</SelectItem>
</SelectContent>
</Select>
</div>
{/* 多选框 Checkbox */}
<div className="flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg">
<div className='flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg'>
<Checkbox
id="allow-notify"
id='allow-notify'
checked={formData.allowNotify}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allowNotify: checked === true }))}
className="mt-0.5"
onCheckedChange={(checked) =>
setFormData((prev) => ({
...prev,
allowNotify: checked === true,
}))
}
className='mt-0.5'
/>
<div className="grid gap-1">
<div className='grid gap-1'>
<label
htmlFor="allow-notify"
className="text-xs font-medium text-foreground cursor-pointer select-none leading-none"
htmlFor='allow-notify'
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
>
允许触发异步通知推送
</label>
<p className="text-[10px] text-muted-foreground leading-normal">
<p className='text-[10px] text-muted-foreground leading-normal'>
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
</p>
</div>
</div>
{/* 按钮变体 */}
<div className="pt-2">
<div className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">按钮样式对齐:</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" className="h-8 text-xs shadow-none">
<div className='pt-2'>
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
按钮样式对齐:
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' className='h-8 text-xs shadow-none'>
主要操作 (Primary)
</Button>
<Button variant="secondary" size="sm" className="h-8 text-xs shadow-none">
<Button
variant='secondary'
size='sm'
className='h-8 text-xs shadow-none'
>
次要操作 (Secondary)
</Button>
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
<Button
variant='outline'
size='sm'
className='h-8 border-dashed text-xs shadow-none'
>
辅助线框 (Outline)
</Button>
<Button variant="ghost" size="sm" className="h-8 text-xs hover:bg-muted">
<Button
variant='ghost'
size='sm'
className='h-8 text-xs hover:bg-muted'
>
幽灵按钮 (Ghost)
</Button>
</div>
@@ -103,38 +151,84 @@ export function ControlsTab() {
</Card>
{/* 控件设计规范说明 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
<Card className='border-dashed shadow-none bg-muted/20'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表单设计规范
</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. 统一控件高度与投影:</p>
<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. 统一控件高度与投影:
</p>
<p>
输入框 (<code className="bg-muted px-1 py-0.5 rounded font-mono">Input</code>) 与下拉选择器 (<code className="bg-muted px-1 py-0.5 rounded font-mono">SelectTrigger</code>) 的高度应统一限制为小号高度 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8</code>,字体大小使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs</code>,并使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">shadow-none</code> 去除原生的重叠阴影。
输入框 (
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
Input
</code>
) 与下拉选择器 (
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
SelectTrigger
</code>
) 的高度应统一限制为小号高度{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8
</code>
,字体大小使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs
</code>
,并使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
shadow-none
</code>{' '}
去除原生的重叠阴影。
</p>
<p className="font-semibold text-foreground text-[11px] pt-1">2. Checkbox 与文本的对齐:</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
2. Checkbox 与文本的对齐:
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-start gap-2.5</code> 排列。Checkbox 组件本身添加 <code className="bg-muted px-1 py-0.5 rounded font-mono">mt-0.5</code>,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-start gap-2.5
</code>{' '}
排列。Checkbox 组件本身添加{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
mt-0.5
</code>
,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
</p>
<p className="font-semibold text-foreground text-[11px] pt-1">3. 按钮使用策略:</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
3. 按钮使用策略:
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'variant="secondary"'}</code> 按钮,小号高度统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8 text-xs</code>。线框按钮必须带有 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 类。
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'variant="secondary"'}
</code>{' '}
按钮,小号高度统一采用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8 text-xs
</code>
。线框按钮必须带有{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
类。
</p>
</div>
<div className="bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto">
<div className='bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto'>
<div>{`// 1. Label 与 Input 对齐`}</div>
<div>{`<div className="space-y-1.5">
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
<Input id="id" className="h-8 text-xs shadow-none" />
</div>`}</div>
<div className="pt-2">{`// 2. Checkbox 顶对齐`}</div>
<div className='pt-2'>{`// 2. Checkbox 顶对齐`}</div>
<div>{`<div className="flex items-start gap-2.5">
<Checkbox id="c" className="mt-0.5" />
<div className="grid gap-1">
@@ -146,5 +240,5 @@ export function ControlsTab() {
</CardContent>
</Card>
</div>
)
);
}
@@ -1,61 +1,81 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {Code, Cpu, Database, HardDrive} from "lucide-react"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
import {Progress} from "@/components/ui/progress"
import * as React from 'react';
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() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{/* CPU */}
<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 使用率</span>
<Cpu className="size-4 text-primary" />
<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 使用率
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<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" />
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>42.8 %</div>
<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' />
容器运行正常,负载平稳
</p>
</CardContent>
</Card>
{/* Storage */}
<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">存储已用容量</span>
<HardDrive className="size-4 text-primary" />
<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'>
存储已用容量
</span>
<HardDrive className='size-4 text-primary' />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">72.4 GB <span className="text-xs text-muted-foreground">/ 100 GB</span></div>
<Progress value={72.4} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>
72.4 GB{' '}
<span className='text-xs text-muted-foreground'>/ 100 GB</span>
</div>
<Progress value={72.4} className='h-1.5' />
<p className='text-[10px] text-muted-foreground'>
数据已同步远端 S3 存储桶,支持自动过期
</p>
</CardContent>
</Card>
{/* DB Overview */}
<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">物理数据库</span>
<Database className="size-4 text-primary" />
<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'>
物理数据库
</span>
<Database className='size-4 text-primary' />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">PostgreSQL <span className="text-xs font-normal text-muted-foreground">(v16.2)</span></div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div>活跃连接: <span className="font-semibold text-foreground">12</span></div>
<div>总表数量: <span className="font-semibold text-foreground">34</span></div>
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>
PostgreSQL{' '}
<span className='text-xs font-normal text-muted-foreground'>
(v16.2)
</span>
</div>
<p className="text-[10px] text-muted-foreground">
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
<div>
活跃连接:{' '}
<span className='font-semibold text-foreground'>12</span>
</div>
<div>
总表数量:{' '}
<span className='font-semibold text-foreground'>34</span>
</div>
</div>
<p className='text-[10px] text-muted-foreground'>
链接池自动维护中,最大连接限制为 100
</p>
</CardContent>
@@ -63,23 +83,53 @@ export function DashboardTab() {
</div>
{/* 指标卡片规范与代码 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
<Card className='border-dashed shadow-none bg-muted/20'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
指标卡片 & 仪表盘设计规范
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li>指标卡片统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 作为边框和投影。</li>
<li>头部使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex flex-row items-center justify-between pb-2</code> 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。</li>
<li>数值强调使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-2xl font-semibold tracking-tight</code> 展示。</li>
<li>进度条使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-1.5</code> 细条高度,不喧宾夺主。</li>
<li>底部的次要说明信息必须统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-[10px] text-muted-foreground</code> 等辅助类。</li>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
指标卡片统一采用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
作为边框和投影。
</li>
<li>
头部使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex flex-row items-center justify-between pb-2
</code>{' '}
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
</li>
<li>
数值强调使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-2xl font-semibold tracking-tight
</code>{' '}
展示。
</li>
<li>
进度条使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-1.5
</code>{' '}
细条高度,不喧宾夺主。
</li>
<li>
底部的次要说明信息必须统一使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-[10px] text-muted-foreground
</code>{' '}
等辅助类。
</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">
{`<Card className="border-dashed shadow-none">
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<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">指标标题</span>
<Cpu className="size-4 text-primary" />
@@ -94,5 +144,5 @@ export function DashboardTab() {
</CardContent>
</Card>
</div>
)
);
}
+186 -68
View File
@@ -1,92 +1,171 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {Code, Edit, ListFilter, Plus, Search, Trash2} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Badge} from "@/components/ui/badge"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
import * as React from 'react';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const mockTableData = [
{ id: "TX-1002", name: "用户信息批量同步导出", type: "数据同步", status: "success", duration: "1.2s", time: "2026-06-15 14:22" },
{ id: "TX-1003", name: "系统数据库每周自动备份归档", type: "数据库运维", status: "running", duration: "在执行中", time: "2026-06-15 15:00" },
{ id: "TX-1004", name: "远端S3过期缓存对象自动清理任务", type: "磁盘清理", status: "failed", duration: "0.4s", time: "2026-06-15 10:05" },
]
{
id: 'TX-1002',
name: '用户信息批量同步导出',
type: '数据同步',
status: 'success',
duration: '1.2s',
time: '2026-06-15 14:22',
},
{
id: 'TX-1003',
name: '系统数据库每周自动备份归档',
type: '数据库运维',
status: 'running',
duration: '在执行中',
time: '2026-06-15 15:00',
},
{
id: 'TX-1004',
name: '远端S3过期缓存对象自动清理任务',
type: '磁盘清理',
status: 'failed',
duration: '0.4s',
time: '2026-06-15 10:05',
},
];
return (
<div className="space-y-6">
<div className='space-y-6'>
{/* 表格控制工具栏 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-2.5 top-2.5 size-3 text-muted-foreground" />
<Input placeholder="输入任务名称搜索..." className="h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background" />
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3'>
<div className='flex items-center gap-2'>
<div className='relative w-64'>
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
<Input
placeholder='输入任务名称搜索...'
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
/>
</div>
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
<ListFilter className="size-3 mr-1" />
<Button
variant='outline'
size='sm'
className='h-8 border-dashed text-xs shadow-none'
>
<ListFilter className='size-3 mr-1' />
过滤
</Button>
</div>
<Button size="sm" className="h-8 text-xs shadow-none">
<Plus className="size-3.5 mr-1" />
<Button size='sm' className='h-8 text-xs shadow-none'>
<Plus className='size-3.5 mr-1' />
新建任务
</Button>
</div>
{/* 数据表格本身 */}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="w-[100px] text-xs font-semibold">任务编号</TableHead>
<TableHead className="text-xs font-semibold">执行任务名称</TableHead>
<TableHead className="w-[120px] text-xs font-semibold">任务类型</TableHead>
<TableHead className="w-[100px] text-xs font-semibold">状态</TableHead>
<TableHead className="w-[100px] text-xs font-semibold">执行耗时</TableHead>
<TableHead className="w-[150px] text-xs font-semibold">触发时间</TableHead>
<TableHead className="w-[80px] text-xs font-semibold text-right">操作</TableHead>
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
<Table className='w-full caption-bottom text-sm min-w-full'>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='w-[100px] text-xs font-semibold'>
任务编号
</TableHead>
<TableHead className='text-xs font-semibold'>
执行任务名称
</TableHead>
<TableHead className='w-[120px] text-xs font-semibold'>
任务类型
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
状态
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
执行耗时
</TableHead>
<TableHead className='w-[150px] text-xs font-semibold'>
触发时间
</TableHead>
<TableHead className='w-[80px] text-xs font-semibold text-right'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockTableData.map((row) => (
<TableRow key={row.id} className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs font-semibold">{row.id}</TableCell>
<TableCell className="text-xs font-medium text-foreground">{row.name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.type}</TableCell>
<TableRow
key={row.id}
className='border-dashed hover:bg-muted/10 transition-colors'
>
<TableCell className='font-mono text-xs font-semibold'>
{row.id}
</TableCell>
<TableCell className='text-xs font-medium text-foreground'>
{row.name}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{row.type}
</TableCell>
<TableCell>
{row.status === "success" && (
<Badge variant="outline" 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" />
{row.status === 'success' && (
<Badge
variant='outline'
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' />
成功
</Badge>
)}
{row.status === "running" && (
<Badge variant="outline" 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" />
{row.status === 'running' && (
<Badge
variant='outline'
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' />
运行中
</Badge>
)}
{row.status === "failed" && (
<Badge variant="outline" 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" />
{row.status === 'failed' && (
<Badge
variant='outline'
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' />
失败
</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">{row.duration}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.time}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-muted text-muted-foreground">
<Edit className="size-3" />
<TableCell className='text-xs text-muted-foreground font-mono'>
{row.duration}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{row.time}
</TableCell>
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1.5'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 rounded hover:bg-muted text-muted-foreground'
>
<Edit className='size-3' />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-destructive/10 text-destructive">
<Trash2 className="size-3" />
<Button
variant='ghost'
size='icon'
className='h-6 w-6 rounded hover:bg-destructive/10 text-destructive'
>
<Trash2 className='size-3' />
</Button>
</div>
</TableCell>
@@ -97,23 +176,62 @@ export function TableTab() {
</div>
{/* 表格规范指引 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
<Card className='border-dashed shadow-none bg-muted/20'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表格设计规范与代码模板
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li>表格容器必须统一使用带有虚线边框的容器,即类名:<code className="bg-muted px-1 py-0.5 rounded font-mono">border border-dashed shadow-none rounded-lg overflow-hidden bg-background</code>。</li>
<li>表头背景设为 <code className="bg-muted px-1 py-0.5 rounded font-mono">bg-muted/40</code>。表头表项字号统一为小字粗体 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs font-semibold</code>。</li>
<li>单元格行与列之间使用虚线分割(<code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed</code>)。表格行悬浮交互采用温和过渡色 <code className="bg-muted px-1 py-0.5 rounded font-mono">hover:bg-muted/10 transition-colors</code>。</li>
<li>编码、主键或唯一 ID 类字段一律采用等宽字体呈现:<code className="bg-muted px-1 py-0.5 rounded font-mono">font-mono text-xs</code>。</li>
<li>状态标签(Badge)尽量圆角化(<code className="bg-muted px-1 py-0.5 rounded font-mono">rounded-full py-0 px-2</code>),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。</li>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
表格容器必须统一使用带有虚线边框的容器,即类名:
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
</code>
。
</li>
<li>
表头背景设为{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
bg-muted/40
</code>
。表头表项字号统一为小字粗体{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
</li>
<li>
单元格行与列之间使用虚线分割(
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed
</code>
)。表格行悬浮交互采用温和过渡色{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
</li>
<li>
编码、主键或唯一 ID 类字段一律采用等宽字体呈现:
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
</li>
<li>
状态标签(Badge)尽量圆角化(
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
rounded-full py-0 px-2
</code>
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
</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">
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
@@ -138,5 +256,5 @@ export function TableTab() {
</CardContent>
</Card>
</div>
)
);
}
@@ -1,8 +1,8 @@
"use client"
'use client';
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react"
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import {
Download,
Eye,
@@ -16,14 +16,21 @@ import {
Trash2,
Upload,
X,
} from "lucide-react"
import {toast} from "sonner"
} from 'lucide-react';
import { toast } from 'sonner';
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Checkbox} from "@/components/ui/checkbox"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
@@ -33,207 +40,236 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {FileImagePreview} from "@/components/common/file-image-preview"
import services, {formatFileSize} from "@/lib/services"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
} from '@/components/ui/alert-dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { FileImagePreview } from '@/components/common/file-image-preview';
import services, { formatFileSize } from '@/lib/services';
import type { Upload as UploadRecord } from '@/lib/services/upload/types';
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-8") {
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
function getFileIcon(mimeType: string, className = 'size-8') {
if (mimeType.startsWith('image/'))
return <FileImage className={`${className} text-blue-400`} />;
if (mimeType.startsWith('video/'))
return <FileVideo className={`${className} text-purple-400`} />;
if (mimeType.startsWith('audio/'))
return <FileAudio className={`${className} text-green-400`} />;
if (
mimeType.includes('zip') ||
mimeType.includes('tar') ||
mimeType.includes('gzip')
)
return <FileArchive className={`${className} text-amber-400`} />;
return <FileText className={`${className} text-slate-400`} />;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function FileList() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
const [page, setPage] = React.useState(1)
const pageSize = 15 // 表格视图下 15 条更紧凑
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(
null,
);
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(
null,
);
const [page, setPage] = React.useState(1);
const pageSize = 15; // 表格视图下 15 条更紧凑
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
setDebouncedKeyword(keyword);
setPage(1);
}, 400);
return () => clearTimeout(timer);
}, [keyword]);
// 文件列表查询
const listQuery = useQuery({
queryKey: ["files", "all", page, pageSize, debouncedKeyword],
queryFn: () => services.adminUpload.listUploads(page, pageSize, debouncedKeyword || undefined),
})
queryKey: ['files', 'all', page, pageSize, debouncedKeyword],
queryFn: () =>
services.adminUpload.listUploads(
page,
pageSize,
debouncedKeyword || undefined,
),
});
const storageDriverQuery = useQuery({
queryKey: ["admin", "storage-config", "driver"],
queryKey: ['admin', 'storage-config', 'driver'],
queryFn: async () => {
const record = await services.adminSystemConfig.getSystemConfig("storage_config")
const cfg = JSON.parse(record.value) as {driver?: string}
return cfg.driver ?? "local"
const record =
await services.adminSystemConfig.getSystemConfig('storage_config');
const cfg = JSON.parse(record.value) as { driver?: string };
return cfg.driver ?? 'local';
},
})
});
const files = listQuery.data?.items ?? []
const total = listQuery.data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
const files = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
const isAllSelected =
files.length > 0 && files.every((f) => selectedIds.has(f.id));
const isSomeSelected =
files.length > 0 &&
files.some((f) => selectedIds.has(f.id)) &&
!isAllSelected;
const handleSelectAll = () => {
if (isAllSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f.id))
return next
})
const next = new Set(prev);
files.forEach((f) => next.delete(f.id));
return next;
});
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.add(f.id))
return next
})
const next = new Set(prev);
files.forEach((f) => next.add(f.id));
return next;
});
}
}
};
// 删除单文件
const deleteMutation = useMutation({
mutationFn: (id: string) => services.adminUpload.deleteFile(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
toast.success("文件已删除")
setDeleteTarget(null)
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
toast.success('文件已删除');
setDeleteTarget(null);
},
onError: (err: Error) => toast.error(err.message || "删除失败"),
})
onError: (err: Error) => toast.error(err.message || '删除失败'),
});
// 批量 ZIP 下载
const batchDownloadMutation = useMutation({
mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "batch_download.zip"
a.click()
URL.revokeObjectURL(url)
toast.success("批量下载已开始")
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'batch_download.zip';
a.click();
URL.revokeObjectURL(url);
toast.success('批量下载已开始');
},
onError: () => toast.error("批量下载失败"),
})
onError: () => toast.error('批量下载失败'),
});
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (next.has(id)) {
next.delete(id)
next.delete(id);
} else {
next.add(id)
next.add(id);
}
return next
})
}
return next;
});
};
const clearSelection = () => setSelectedIds(new Set())
const clearSelection = () => setSelectedIds(new Set());
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)));
const handleDownload = (file: UploadRecord) => {
const url = services.adminUpload.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
const url = services.adminUpload.getDownloadUrl(file.id);
const a = document.createElement('a');
a.href = url;
a.download = file.file_name;
a.click();
};
return (
<div className="space-y-6">
<div className='space-y-6'>
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<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" />
<div className='flex items-center gap-3'>
<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="搜索文件名..."
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
placeholder='搜索文件名...'
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{keyword && (
<button
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setKeyword("")}
className='absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground'
onClick={() => setKeyword('')}
>
<X className="size-3" />
<X className='size-3' />
</button>
)}
</div>
{files.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-xs h-8 border border-dashed text-muted-foreground"
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
size='sm'
variant='ghost'
className='text-xs h-8 border border-dashed text-muted-foreground'
onClick={
selectedIds.size === files.length ? clearSelection : selectAll
}
>
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
</Button>
)}
{/* 打包下载等批量操作 */}
<div className="flex items-center gap-2 ml-auto shrink-0">
<div className='flex items-center gap-2 ml-auto shrink-0'>
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2"
className='flex items-center gap-2'
>
<Badge variant="secondary" className="text-xs px-2.5">
<Badge variant='secondary' className='text-xs px-2.5'>
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
size='sm'
variant='outline'
className='border-dashed text-xs h-8'
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
disabled={batchDownloadMutation.isPending}
>
{batchDownloadMutation.isPending ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<FileArchive className="size-3.5 mr-1" />
<FileArchive className='size-3.5 mr-1' />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
size='sm'
variant='ghost'
className='text-xs h-8 px-2'
onClick={clearSelection}
>
<X className="size-3.5" />
<X className='size-3.5' />
</Button>
</motion.div>
)}
@@ -241,131 +277,152 @@ export function FileList() {
</div>
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
<span className='text-xs text-muted-foreground shrink-0'>
共 {total} 个文件
</span>
)}
</div>
{/* 文件列表 Table */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
<div className='flex items-center justify-center py-20'>
<Loader2 className='size-6 animate-spin text-sky-500' />
</div>
) : files.length === 0 ? (
<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 ? "没有匹配的文件" : "您还没有上传任何文件"}
<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 ? '没有匹配的文件' : '您还没有上传任何文件'}
</p>
</div>
) : (
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
<div className='border border-dashed rounded-xl bg-card overflow-hidden'>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-dashed">
<TableHead className="w-[50px] pl-4 py-3">
<TableRow className='hover:bg-transparent border-dashed'>
<TableHead className='w-[50px] pl-4 py-3'>
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
checked={
isAllSelected ||
(isSomeSelected ? 'indeterminate' : false)
}
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>
<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">操作</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>
<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'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const isSelected = selectedIds.has(file.id)
const isSelected = selectedIds.has(file.id);
return (
<TableRow
key={file.id}
className={`border-dashed hover:bg-muted/30 transition-colors ${
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
isSelected ? 'bg-sky-500/5 hover:bg-sky-500/10' : ''
}`}
>
<TableCell className="pl-4 py-3">
<TableCell className='pl-4 py-3'>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
{file.mime_type.startsWith("image/") ? (
<TableCell className='py-3'>
<div className='flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border'>
{file.mime_type.startsWith('image/') ? (
<FileImagePreview
fileId={file.id}
alt={file.file_name}
quality="low"
variant="compact"
className="size-full object-cover"
quality='low'
variant='compact'
className='size-full object-cover'
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
getFileIcon(file.mime_type, 'size-4.5')
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
<TableCell className='py-3 font-mono text-[11px] text-muted-foreground select-all'>
{file.id}
</TableCell>
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
<TableCell
className='py-3 font-medium max-w-[180px] truncate text-xs'
title={file.file_name}
>
{file.file_name}
</TableCell>
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
<TableCell
className='py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all'
title={file.file_path}
>
{file.file_path}
</TableCell>
<TableCell className="py-3">
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
<TableCell className='py-3'>
<Badge
variant='secondary'
className='text-[10px] py-0 px-1.5 font-normal rounded-md'
>
{file.type}
</Badge>
</TableCell>
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
<TableCell
className='py-3 text-[11px] text-muted-foreground truncate max-w-[125px]'
title={file.mime_type}
>
{file.mime_type}
</TableCell>
<TableCell className="py-3 text-xs">
<TableCell className='py-3 text-xs'>
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
<TableCell className='py-3 text-xs text-muted-foreground'>
{formatDate(file.created_at)}
</TableCell>
<TableCell className="py-3 text-right pr-4">
<div className="flex items-center justify-end gap-0.5">
<TableCell className='py-3 text-right pr-4'>
<div className='flex items-center justify-end gap-0.5'>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看详情"
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='查看详情'
onClick={() => setDetailTarget(file)}
>
<Eye className="size-3.5" />
<Eye className='size-3.5' />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted'
title='下载'
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
<Download className='size-3.5' />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
title='删除'
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
<Trash2 className='size-3.5' />
</Button>
</div>
</TableCell>
</TableRow>
)
);
})}
</TableBody>
</Table>
@@ -374,23 +431,23 @@ export function FileList() {
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<div className='flex items-center justify-center gap-2 pt-2'>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
size='sm'
variant='outline'
className='border-dashed text-xs h-7 px-3'
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs text-muted-foreground">
<span className='text-xs text-muted-foreground'>
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
size='sm'
variant='outline'
className='border-dashed text-xs h-7 px-3'
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
@@ -400,29 +457,34 @@ export function FileList() {
)}
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<Sheet
open={!!detailTarget}
onOpenChange={(open) => !open && setDetailTarget(null)}
>
<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>
<SheetDescription>
查看文件的完整元数据与配置属性。
</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
<div className='space-y-6 py-4'>
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
<div className='flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2'>
{detailTarget.mime_type.startsWith('image/') ? (
<FileImagePreview
fileId={detailTarget.id}
alt={detailTarget.file_name}
quality="low"
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
fallbackClassName="min-h-32 w-full rounded-lg"
quality='low'
className='max-h-full max-w-full object-contain rounded-lg shadow-sm'
fallbackClassName='min-h-32 w-full rounded-lg'
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
<div className='flex flex-col items-center gap-3'>
{getFileIcon(detailTarget.mime_type, 'size-14')}
<span className='text-xs font-medium text-muted-foreground uppercase'>
{detailTarget.extension} 文件
</span>
</div>
@@ -430,76 +492,123 @@ export function FileList() {
</div>
{/* 元数据列表 */}
<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</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
<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
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.id}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件名
</span>
<span className='col-span-2 break-all font-medium text-foreground/90'>
{detailTarget.file_name}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储路径
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.file_path}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
大小
</span>
<span className='col-span-2 text-foreground/90'>
{formatFileSize(detailTarget.file_size)} (
{detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
MIME 类型
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.mime_type}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件后缀
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.extension}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
Hash (SHA-256)
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.hash}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
业务类型
</span>
<span className='col-span-2'>
<Badge
variant='secondary'
className='text-[10px] py-0 px-2 rounded-md font-normal'
>
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{storageDriverQuery.data ?? "local"}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储驱动
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{storageDriverQuery.data ?? 'local'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传者 ID
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.user_id}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传时间
</span>
<span className='col-span-2 text-foreground/90'>
{formatDate(detailTarget.created_at)}
</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</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)}
</pre>
</div>
)}
{detailTarget.metadata &&
Object.keys(detailTarget.metadata).length > 0 && (
<div className='space-y-1.5 pt-2'>
<span className='text-muted-foreground font-medium block'>
额外元数据
</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)}
</pre>
</div>
)}
</div>
</div>
)}
@@ -507,29 +616,40 @@ export function FileList() {
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
);
}
@@ -1,12 +1,35 @@
"use client"
'use client';
import * as React from "react"
import {useQuery} from "@tanstack/react-query"
import {Database, Files, HardDrive, Info, Loader2, TrendingUp, Upload,} from "lucide-react"
import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts"
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Database,
Files,
HardDrive,
Info,
Loader2,
TrendingUp,
Upload,
} from 'lucide-react';
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Pie,
PieChart,
XAxis,
YAxis,
} from 'recharts';
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
@@ -14,265 +37,322 @@ import {
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import services, {formatFileSize} from "@/lib/services"
} from '@/components/ui/chart';
import services, { formatFileSize } from '@/lib/services';
const categoryMap: Record<string, string> = {
"图片": "images",
"视频": "videos",
"音频": "audio",
"文档": "documents",
"压缩包": "archives",
"其他": "others",
}
图片: 'images',
视频: 'videos',
音频: 'audio',
文档: 'documents',
压缩包: 'archives',
其他: 'others',
};
const statsChartConfig = {
images: {
label: "图片",
color: "hsl(var(--chart-1))",
label: '图片',
color: 'hsl(var(--chart-1))',
},
videos: {
label: "视频",
color: "hsl(var(--chart-2))",
label: '视频',
color: 'hsl(var(--chart-2))',
},
audio: {
label: "音频",
color: "hsl(var(--chart-3))",
label: '音频',
color: 'hsl(var(--chart-3))',
},
documents: {
label: "文档",
color: "hsl(var(--chart-4))",
label: '文档',
color: 'hsl(var(--chart-4))',
},
archives: {
label: "压缩包",
color: "hsl(var(--chart-5))",
label: '压缩包',
color: 'hsl(var(--chart-5))',
},
others: {
label: "其他",
color: "hsl(var(--chart-6))",
label: '其他',
color: 'hsl(var(--chart-6))',
},
count: {
label: "新增文件数",
color: "hsl(var(--primary))",
label: '新增文件数',
color: 'hsl(var(--primary))',
},
size: {
label: "新增大小",
color: "hsl(var(--chart-2))",
label: '新增大小',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig
} satisfies ChartConfig;
export function FileStats() {
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count',
);
const statsQuery = useQuery({
queryKey: ["files", "stats"],
queryKey: ['files', 'stats'],
queryFn: () => services.adminUpload.getFileStats(),
staleTime: 0,
refetchOnMount: "always",
})
refetchOnMount: 'always',
});
const stats = statsQuery.data
const stats = statsQuery.data;
const trendData = React.useMemo(() => {
if (!stats?.trend) return []
if (!stats?.trend) return [];
return stats.trend.map((item) => ({
...item,
formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD
value: trendMetric === "count" ? item.count : item.size,
}))
}, [stats?.trend, trendMetric])
value: trendMetric === 'count' ? item.count : item.size,
}));
}, [stats?.trend, trendMetric]);
const categoryCountData = React.useMemo(() => {
if (!stats?.categories) return []
if (!stats?.categories) return [];
return stats.categories
.filter((c) => c.count > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
const key = categoryMap[c.name] || 'others';
return {
name: key,
value: c.count,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
};
});
}, [stats?.categories]);
const categorySizeData = React.useMemo(() => {
if (!stats?.categories) return []
if (!stats?.categories) return [];
return stats.categories
.filter((c) => c.size > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
const key = categoryMap[c.name] || 'others';
return {
name: key,
value: c.size,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
};
});
}, [stats?.categories]);
const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) {
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
}
let maxCountName = "无"
let maxCount = 0
let maxSizeName = "无"
let maxSize = 0
let maxCountName = '无';
let maxCount = 0;
let maxSizeName = '无';
let maxSize = 0;
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count
maxCountName = cat.name
maxCount = cat.count;
maxCountName = cat.name;
}
if (cat.size > maxSize) {
maxSize = cat.size
maxSizeName = cat.name
maxSize = cat.size;
maxSizeName = cat.name;
}
})
});
return { maxCountName, maxCount, maxSizeName, maxSize }
}, [stats?.categories])
return { maxCountName, maxCount, maxSizeName, maxSize };
}, [stats?.categories]);
const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 }
if (!stats?.trend) return { count: 0, size: 0 };
return stats.trend.reduce(
(acc, curr) => ({
count: acc.count + curr.count,
size: acc.size + curr.size,
}),
{ count: 0, size: 0 }
)
}, [stats?.trend])
{ count: 0, size: 0 },
);
}, [stats?.trend]);
if (statsQuery.isLoading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="size-8 animate-spin text-sky-500" />
<div className='flex items-center justify-center py-32'>
<Loader2 className='size-8 animate-spin text-sky-500' />
</div>
)
);
}
if (!stats || stats.total_count === 0) {
return (
<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="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
<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='text-xs text-muted-foreground'>
上传文件后即可在此查看容量与文件分布分析。
</p>
</div>
</div>
)
);
}
return (
<div className="space-y-6">
<div className='space-y-6'>
{/* 四个指标卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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">总文件数</CardTitle>
<Files className="size-4 text-sky-500" />
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
<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'>
总文件数
</CardTitle>
<Files className='size-4 text-sky-500' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-sky-500">
{stats.total_count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
<div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
<p className='text-[10px] text-muted-foreground mt-1'>
您上传的可用文件数量汇总
</p>
</CardContent>
</Card>
<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">占用存储容量</CardTitle>
<Database className="size-4 text-emerald-500" />
<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'>
占用存储容量
</CardTitle>
<Database className='size-4 text-emerald-500' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
</CardContent>
</Card>
<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 天新增文件</CardTitle>
<TrendingUp className="size-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-indigo-500">
+{trendSummary.count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
<div className='text-2xl font-bold font-mono text-emerald-500'>
{formatFileSize(stats.total_size)}
</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
<p className='text-[10px] text-muted-foreground mt-1'>
文件实际在磁盘/S3中占用的总空间
</p>
</CardContent>
</Card>
<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 天新增大小</CardTitle>
<HardDrive className="size-4 text-amber-500" />
<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 天新增文件
</CardTitle>
<TrendingUp className='size-4 text-indigo-500' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
<div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传成功的文件总数
</p>
</CardContent>
</Card>
<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 天新增大小
</CardTitle>
<HardDrive className='size-4 text-amber-500' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold font-mono text-amber-500'>
+{formatFileSize(trendSummary.size)}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传所消耗的存储带宽
</p>
</CardContent>
</Card>
</div>
{/* 7 天新增趋势折线图 */}
<Card className="bg-card/20 border-border/40">
<CardHeader className="flex flex-row items-center justify-between">
<Card className='bg-card/20 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
<CardTitle className='text-sm font-bold'>
最近 7 天新增趋势
</CardTitle>
<CardDescription className='text-xs'>
按日统计近一周期内的上传波动
</CardDescription>
</div>
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'>
<Button
size="sm"
variant={trendMetric === "count" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("count")}
size='sm'
variant={trendMetric === 'count' ? 'secondary' : 'ghost'}
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('count')}
>
新增数量 (个)
</Button>
<Button
size="sm"
variant={trendMetric === "size" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("size")}
size='sm'
variant={trendMetric === 'size' ? 'secondary' : 'ghost'}
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('size')}
>
新增大小 (MB)
</Button>
</div>
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<div className="h-[280px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CardContent className='pl-2 pr-4 pt-2'>
<div className='h-[280px] w-full'>
<ChartContainer config={statsChartConfig} className='w-full h-full'>
<AreaChart
data={trendData}
margin={{ top: 10, right: 10, left: 10, bottom: 0 }}
>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
<linearGradient id='colorCount' x1='0' y1='0' x2='0' y2='1'>
<stop
offset='5%'
stopColor='var(--color-count)'
stopOpacity={0.3}
/>
<stop
offset='95%'
stopColor='var(--color-count)'
stopOpacity={0.01}
/>
</linearGradient>
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
<linearGradient id='colorSize' x1='0' y1='0' x2='0' y2='1'>
<stop
offset='5%'
stopColor='var(--color-size)'
stopOpacity={0.3}
/>
<stop
offset='95%'
stopColor='var(--color-size)'
stopOpacity={0.01}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
<CartesianGrid
strokeDasharray='3 3'
vertical={false}
stroke='hsl(var(--border)/40)'
/>
<XAxis
dataKey="formattedDate"
dataKey='formattedDate'
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(v) =>
trendMetric === 'size' ? formatFileSize(v) : v.toString()
}
/>
<ChartTooltip
cursor={false}
@@ -280,37 +360,52 @@ export function FileStats() {
<ChartTooltipContent
hideLabel
formatter={(value) => {
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
const label =
trendMetric === 'count' ? '新增文件数' : '新增大小';
const formattedValue =
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} 个`;
return (
<>
<div className="flex items-center gap-1.5">
<div className='flex items-center gap-1.5'>
<span
className="size-2 rounded-full animate-pulse"
className='size-2 rounded-full animate-pulse'
style={{
backgroundColor:
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
trendMetric === 'count'
? 'var(--color-count)'
: 'var(--color-size)',
}}
/>
<span className="text-muted-foreground">{label}</span>
<span className='text-muted-foreground'>
{label}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{formattedValue}
</span>
</>
)
);
}}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
type='monotone'
dataKey='value'
stroke={
trendMetric === 'count'
? 'var(--color-count)'
: 'var(--color-size)'
}
strokeWidth={2.5}
fillOpacity={1}
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
fill={
trendMetric === 'count'
? 'url(#colorCount)'
: 'url(#colorSize)'
}
/>
</AreaChart>
</ChartContainer>
@@ -319,30 +414,37 @@ export function FileStats() {
</Card>
{/* 两个饼图分布 */}
<div className="grid gap-6 md:grid-cols-2">
<div className='grid gap-6 md:grid-cols-2'>
{/* 饼图 1: 格式数量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
<CardTitle className='text-sm font-bold'>
文件格式数量分布
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件个数占比
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
<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">
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无分类数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<div className='h-[240px] w-full'>
<ChartContainer
config={statsChartConfig}
className='w-full h-full'
>
<PieChart>
<Pie
data={categoryCountData}
cx="50%"
cy="50%"
cx='50%'
cy='50%'
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
dataKey='value'
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
@@ -354,66 +456,88 @@ export function FileStats() {
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
const configObj =
statsChartConfig[
name as keyof typeof statsChartConfig
];
const label = configObj?.label || name;
const color =
configObj?.color || 'hsl(var(--muted))';
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
<div className='flex items-center gap-1.5'>
<span
className='size-2 rounded-full'
style={{ backgroundColor: color }}
/>
<span className='text-muted-foreground'>
{label}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} 个
</span>
</>
)
);
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
/>
</PieChart>
</ChartContainer>
</div>
)}
<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> 个文件。
<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>{' '}
个文件。
</p>
</div>
</CardContent>
</Card>
{/* 饼图 2: 格式容量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
<CardTitle className='text-sm font-bold'>
文件占用容量分布
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件大小占比
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
<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">
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无容量数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<div className='h-[240px] w-full'>
<ChartContainer
config={statsChartConfig}
className='w-full h-full'
>
<PieChart>
<Pie
data={categorySizeData}
cx="50%"
cy="50%"
cx='50%'
cy='50%'
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
dataKey='value'
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
@@ -425,38 +549,52 @@ export function FileStats() {
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
const configObj =
statsChartConfig[
name as keyof typeof statsChartConfig
];
const label = configObj?.label || name;
const color =
configObj?.color || 'hsl(var(--muted))';
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
<div className='flex items-center gap-1.5'>
<span
className='size-2 rounded-full'
style={{ backgroundColor: color }}
/>
<span className='text-muted-foreground'>
{label}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{formatFileSize(Number(value))}
</span>
</>
)
);
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
/>
</PieChart>
</ChartContainer>
</div>
)}
<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>{" "}
<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>{' '}
存储容量。
</p>
</div>
@@ -464,5 +602,5 @@ export function FileStats() {
</Card>
</div>
</div>
)
);
}
@@ -1,104 +1,130 @@
"use client"
'use client';
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Database, Loader2, Play, Save} from "lucide-react"
import {toast} from "sonner"
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Loader2, Play, Save } from 'lucide-react';
import { toast } from 'sonner';
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
import {Input} from "@/components/ui/input"
import {Progress} from "@/components/ui/progress"
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import services from '@/lib/services';
import type {
ObjectStorageConfig,
StorageConfig,
StorageDriver,
TaskExecution,
TaskExecutionStatus
} from "@/lib/services/admin/types"
TaskExecutionStatus,
} from '@/lib/services/admin/types';
const storageConfigKey = "storage_config"
const storageMigrationTaskType = "storage_migration"
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",
}
local: '本地文件系统',
s3: 'AWS S3',
r2: 'Cloudflare R2',
minio: 'MinIO',
oss: '阿里云 OSS',
webdav: 'WebDAV',
};
const emptyObjectConfig: ObjectStorageConfig = {
endpoint: "",
region: "",
bucket: "",
access_key_id: "",
secret_access_key: "",
endpoint: '',
region: '',
bucket: '',
access_key_id: '',
secret_access_key: '',
path_style: false,
key_prefix: "",
cdn_url: "",
}
key_prefix: '',
cdn_url: '',
};
function normalizeConfig(config: StorageConfig): StorageConfig {
const s3 = {...emptyObjectConfig, ...config.s3}
const r2 = {...emptyObjectConfig, ...config.r2}
const minio = {...emptyObjectConfig, ...config.minio}
const oss = {...emptyObjectConfig, ...config.oss}
const webdav = {...config.webdav}
const s3 = { ...emptyObjectConfig, ...config.s3 };
const r2 = { ...emptyObjectConfig, ...config.r2 };
const minio = { ...emptyObjectConfig, ...config.minio };
const oss = { ...emptyObjectConfig, ...config.oss };
const webdav = { ...config.webdav };
return {
...config,
local: {root: config.local?.root || "."},
s3: {...s3, region: s3.region || "us-east-1"},
r2: {...r2, region: r2.region || "auto"},
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
local: { root: config.local?.root || '.' },
s3: { ...s3, region: s3.region || 'us-east-1' },
r2: { ...r2, region: r2.region || 'auto' },
minio: {
...minio,
region: minio.region || 'us-east-1',
path_style: config.minio?.path_style ?? true,
},
oss,
webdav: {
endpoint: webdav.endpoint || "",
username: webdav.username || "",
password: webdav.password || "",
base_path: webdav.base_path || "",
endpoint: webdav.endpoint || '',
username: webdav.username || '',
password: webdav.password || '',
base_path: webdav.base_path || '',
},
}
};
}
type StorageMigrationView = {
state: "idle" | TaskExecutionStatus
id?: string
task_id?: string
source_driver?: StorageDriver
target_driver?: StorageDriver
error?: string
}
state: 'idle' | TaskExecutionStatus;
id?: string;
task_id?: string;
source_driver?: StorageDriver;
target_driver?: StorageDriver;
error?: string;
};
type StorageMigrationPayload = {
target: StorageConfig
}
target: StorageConfig;
};
function parseMigrationPayload(payload: string): StorageConfig | undefined {
try {
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
return parsed.target ? normalizeConfig(parsed.target) : undefined
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>;
return parsed.target ? normalizeConfig(parsed.target) : undefined;
} catch {
return undefined
return undefined;
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : "未知"
return driver ? driverLabels[driver] : '未知';
}
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
const execution = executions[0]
if (!execution || execution.status === "succeeded") {
return {state: "idle"}
function latestMigration(
executions: TaskExecution[],
current?: StorageConfig,
): StorageMigrationView {
const execution = executions[0];
if (!execution || execution.status === 'succeeded') {
return { state: 'idle' };
}
const target = parseMigrationPayload(execution.payload)
const target = parseMigrationPayload(execution.payload);
return {
state: execution.status,
id: execution.id,
@@ -106,227 +132,339 @@ function latestMigration(executions: TaskExecution[], current?: StorageConfig):
source_driver: current?.driver,
target_driver: target?.driver,
error: execution.error_message,
}
};
}
export function StorageConfigTab() {
const queryClient = useQueryClient()
const [config, setConfig] = React.useState<StorageConfig | null>(null)
const queryClient = useQueryClient();
const [config, setConfig] = React.useState<StorageConfig | null>(null);
const query = useQuery({
queryKey: ["admin", "storage-config"],
queryKey: ['admin', 'storage-config'],
queryFn: async () => {
const [configRecord, executions] = await Promise.all([
services.adminSystemConfig.getSystemConfig(storageConfigKey),
services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
])
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
const migration = latestMigration(executions.items, current)
services.adminTask.listTaskExecutions({
task_type: storageMigrationTaskType,
page: 1,
page_size: 1,
}),
]);
const current = normalizeConfig(
JSON.parse(configRecord.value) as StorageConfig,
);
const migration = latestMigration(executions.items, current);
return {
config: current,
migration,
}
};
},
refetchInterval: (result) => {
const state = result.state.data?.migration.state
return state === "pending" || state === "running" ? 2000 : false
const state = result.state.data?.migration.state;
return state === 'pending' || state === 'running' ? 2000 : false;
},
})
});
React.useEffect(() => {
if (query.data?.config) {
setConfig((current) => {
const normalized = normalizeConfig(query.data.config)
const normalized = normalizeConfig(query.data.config);
if (current) {
return {
...normalized,
driver: current.driver,
}
};
}
return normalized
})
return normalized;
});
}
}, [query.data?.config])
}, [query.data?.config]);
const saveMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
await services.adminSystemConfig.updateSystemConfig(storageConfigKey, {
value: JSON.stringify(value),
})
});
},
onSuccess: () => {
toast.success("存储配置已保存")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储配置已保存');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || "保存存储配置失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.error(error.message || '保存存储配置失败');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
})
});
const migrateMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
const payload: StorageMigrationPayload = {target: value}
const payload: StorageMigrationPayload = { target: value };
return services.adminTask.dispatchTask({
task_type: storageMigrationTaskType,
payload: JSON.stringify(payload),
})
});
},
onSuccess: () => {
toast.success("存储迁移任务已下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储迁移任务已下发');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || "下发存储迁移任务失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.error(error.message || '下发存储迁移任务失败');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
})
});
const runMutation = useMutation({
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
mutationFn: (executionID: string) =>
services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success("存储迁移任务已重新下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储迁移任务已重新下发');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
})
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
});
if (query.isPending || !config) {
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
return (
<div className='flex justify-center py-20'>
<Loader2 className='animate-spin' />
</div>
);
}
const migration = query.data?.migration
const isReadOnly = migration ? migration.state !== "idle" : false
const migration = query.data?.migration;
const isReadOnly = migration ? migration.state !== 'idle' : false;
const isFormDisabled =
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
(migration
? migration.state === 'pending' || migration.state === 'running'
: false) ||
saveMutation.isPending ||
migrateMutation.isPending
migrateMutation.isPending;
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
setConfig((current) => current ? {
...current,
[driver]: {...current[driver], ...patch},
} : current)
}
const updateObject = (
driver: 's3' | 'r2' | 'minio' | 'oss',
patch: Partial<ObjectStorageConfig>,
) => {
setConfig((current) =>
current
? {
...current,
[driver]: { ...current[driver], ...patch },
}
: current,
);
};
return (
<div className="flex w-full flex-col gap-6">
<div className='flex w-full flex-col gap-6'>
{isReadOnly && migration && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardTitle className='flex items-center gap-2'>
存储维护模式
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
<Badge
variant={
migration.state === 'failed' ? 'destructive' : 'secondary'
}
>
{migration.state}
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
{driverLabel(migration.source_driver)} →{' '}
{driverLabel(migration.target_driver)}
。迁移期间文件只允许读取,禁止上传、删除和清理。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
<p className="text-sm text-muted-foreground">
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
<CardContent className='flex flex-col gap-3'>
<Progress
value={migration.state === 'succeeded' ? 100 : undefined}
/>
<p className='text-sm text-muted-foreground'>
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
</p>
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
{migration.error && (
<p className='text-sm text-destructive'>{migration.error}</p>
)}
</CardContent>
<CardFooter>
<Button
variant="outline"
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
variant='outline'
disabled={
migration.state !== 'failed' ||
!migration.id ||
runMutation.isPending
}
onClick={() => migration.id && runMutation.mutate(migration.id)}
>
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
{runMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Play data-icon='inline-start' />
)}
重试迁移
</Button>
</CardFooter>
</Card>
)}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
<Database className="size-4" />
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<div className='flex items-center gap-2'>
<div className='rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500'>
<Database className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
文件存储
</CardTitle>
<CardDescription className='text-xs'>
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<FieldGroup className="space-y-6">
<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">存储类型</FieldLabel>
<CardContent className='pt-6'>
<FieldGroup className='space-y-6'>
<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'>
存储类型
</FieldLabel>
<Select
value={config.driver}
disabled={isFormDisabled}
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
onValueChange={(value) =>
setConfig({ ...config, driver: value as StorageDriver })
}
>
<SelectTrigger className="w-full border-dashed bg-card text-xs">
<SelectTrigger className='w-full border-dashed bg-card text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
))}
{(Object.keys(driverLabels) as StorageDriver[]).map(
(driver) => (
<SelectItem key={driver} value={driver}>
{driverLabels[driver]}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{config.driver === "local" && (
<div className="md:col-span-2">
{config.driver === 'local' && (
<div className='md:col-span-2'>
<TextField
label="根目录"
label='根目录'
value={config.local.root}
placeholder="."
onChange={(root) => setConfig({...config, local: {root}})}
placeholder='.'
onChange={(root) =>
setConfig({ ...config, local: { root } })
}
/>
</div>
)}
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
{(config.driver === 's3' ||
config.driver === 'r2' ||
config.driver === 'minio' ||
config.driver === 'oss') && (
<ObjectFields
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
driver={config.driver as 's3' | 'r2' | 'minio' | 'oss'}
value={config[config.driver as 's3' | 'r2' | 'minio' | 'oss']}
onChange={(patch) =>
updateObject(
config.driver as 's3' | 'r2' | 'minio' | 'oss',
patch,
)
}
/>
)}
{config.driver === "webdav" && (
{config.driver === 'webdav' && (
<>
<div className="md:col-span-2">
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
<div className='md:col-span-2'>
<TextField
label='服务地址'
value={config.webdav.endpoint}
placeholder='https://dav.example.com'
onChange={(endpoint) =>
setConfig({
...config,
webdav: { ...config.webdav, endpoint },
})
}
/>
</div>
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
<div className="md:col-span-2">
<TextField label="基础路径" value={config.webdav.base_path} placeholder="wavelet" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
<TextField
label='用户名'
value={config.webdav.username}
onChange={(username) =>
setConfig({
...config,
webdav: { ...config.webdav, username },
})
}
/>
<TextField
label='密码'
type='password'
value={config.webdav.password}
onChange={(password) =>
setConfig({
...config,
webdav: { ...config.webdav, password },
})
}
/>
<div className='md:col-span-2'>
<TextField
label='基础路径'
value={config.webdav.base_path}
placeholder='wavelet'
onChange={(base_path) =>
setConfig({
...config,
webdav: { ...config.webdav, base_path },
})
}
/>
</div>
</>
)}
</div>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
<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">
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
⚠️
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
</span>
)}
<div className="flex gap-2">
<div className='flex gap-2'>
<Button
variant="outline"
variant='outline'
disabled={isFormDisabled}
onClick={() => saveMutation.mutate(config)}
className="border-dashed"
className='border-dashed'
>
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
{saveMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Save data-icon='inline-start' />
)}
保存配置
</Button>
{config.driver !== query.data?.config.driver && (
@@ -334,7 +472,11 @@ export function StorageConfigTab() {
disabled={isFormDisabled}
onClick={() => migrateMutation.mutate(config)}
>
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
{migrateMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Play data-icon='inline-start' />
)}
开始迁移
</Button>
)}
@@ -342,7 +484,7 @@ export function StorageConfigTab() {
</CardFooter>
</Card>
</div>
)
);
}
function ObjectFields({
@@ -350,41 +492,87 @@ function ObjectFields({
value,
onChange,
}: {
driver: "s3" | "r2" | "minio" | "oss"
value: ObjectStorageConfig
onChange: (patch: Partial<ObjectStorageConfig>) => void
driver: 's3' | 'r2' | 'minio' | 'oss';
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
return (
<>
{driver === "r2" && (
<div className="md:col-span-2">
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
{driver === 'r2' && (
<div className='md:col-span-2'>
<TextField
label='Account ID'
value={value.account_id || ''}
onChange={(account_id) => onChange({ account_id })}
/>
</div>
)}
{driver !== "s3" && (
<div className="md:col-span-2">
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
{driver !== 's3' && (
<div className='md:col-span-2'>
<TextField
label='Endpoint'
value={value.endpoint}
placeholder='https://...'
onChange={(endpoint) => onChange({ endpoint })}
/>
</div>
)}
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
{(driver === "s3" || driver === "minio") && (
<div className="md:col-span-2">
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
<div className="flex flex-col gap-0.5">
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
<TextField
label='Region'
value={value.region}
onChange={(region) => onChange({ region })}
/>
<TextField
label='Bucket'
value={value.bucket}
onChange={(bucket) => onChange({ bucket })}
/>
<TextField
label='Access Key ID'
value={value.access_key_id}
onChange={(access_key_id) => onChange({ access_key_id })}
/>
<TextField
label='Secret Access Key'
type='password'
value={value.secret_access_key}
onChange={(secret_access_key) => onChange({ secret_access_key })}
/>
<TextField
label='对象前缀'
value={value.key_prefix}
placeholder='uploads'
onChange={(key_prefix) => onChange({ key_prefix })}
/>
<TextField
label='CDN 地址'
value={value.cdn_url}
placeholder='https://cdn.example.com'
onChange={(cdn_url) => onChange({ cdn_url })}
/>
{(driver === 's3' || driver === 'minio') && (
<div className='md:col-span-2'>
<Field
orientation='horizontal'
className='flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30'
>
<div className='flex flex-col gap-0.5'>
<FieldLabel className='text-xs font-semibold cursor-pointer'>
Path Style
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
MinIO 等自托管 S3 通常需要开启。
</FieldDescription>
</div>
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
<Switch
checked={value.path_style}
onCheckedChange={(path_style) => onChange({ path_style })}
/>
</Field>
</div>
)}
</>
)
);
}
function TextField({
@@ -392,24 +580,24 @@ function TextField({
value,
onChange,
placeholder,
type = "text",
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: React.HTMLInputTypeAttribute
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: React.HTMLInputTypeAttribute;
}) {
return (
<Field className="flex flex-col gap-1.5">
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
<Field className='flex flex-col gap-1.5'>
<FieldLabel className='text-xs font-semibold'>{label}</FieldLabel>
<Input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-dashed bg-card text-xs"
className='border-dashed bg-card text-xs'
/>
</Field>
)
);
}
+32 -26
View File
@@ -1,59 +1,65 @@
"use client"
'use client';
import * as React from "react"
import {motion} from "motion/react"
import {FolderOpen} from "lucide-react"
import * as React from 'react';
import { motion } from 'motion/react';
import { FolderOpen } from 'lucide-react';
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {FileStats} from "./components/file-stats"
import {FileList} from "./components/file-list"
import {StorageConfigTab} from "./components/storage-config-tab"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FileStats } from './components/file-stats';
import { FileList } from './components/file-list';
import { StorageConfigTab } from './components/storage-config-tab';
export default function FilesPage() {
const [activeTab, setActiveTab] = React.useState("stats")
const [activeTab, setActiveTab] = React.useState('stats');
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
transition={{ duration: 0.35, ease: 'easeOut' }}
className='flex w-full flex-col gap-6 py-6'
>
{/* 顶部标题区 */}
<div className="flex items-center gap-2">
<FolderOpen className="size-5 text-primary" />
<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'>存储管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="stats" className="px-0 pb-2 text-xs font-semibold">
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='stats'
className='px-0 pb-2 text-xs font-semibold'
>
文件存储信息
</TabsTrigger>
<TabsTrigger value="list" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
文件列表
</TabsTrigger>
<TabsTrigger value="storage" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='storage'
className='px-0 pb-2 text-xs font-semibold'
>
存储配置
</TabsTrigger>
</TabsList>
{/* ──────── TAB 1: 统计看板 ──────── */}
<TabsContent value="stats" className="outline-hidden">
{activeTab === "stats" ? <FileStats /> : null}
<TabsContent value='stats' className='outline-hidden'>
{activeTab === 'stats' ? <FileStats /> : null}
</TabsContent>
{/* ──────── TAB 2: 文件列表 ──────── */}
<TabsContent value="list" className="outline-hidden">
{activeTab === "list" ? <FileList /> : null}
<TabsContent value='list' className='outline-hidden'>
{activeTab === 'list' ? <FileList /> : null}
</TabsContent>
<TabsContent value="storage" className="outline-hidden">
{activeTab === "storage" ? <StorageConfigTab /> : null}
<TabsContent value='storage' className='outline-hidden'>
{activeTab === 'storage' ? <StorageConfigTab /> : null}
</TabsContent>
</Tabs>
</motion.div>
)
);
}
+7 -9
View File
@@ -1,18 +1,16 @@
"use client"
'use client';
import {AdminUsersProvider} from "@/contexts/admin-users-context"
import {RequireAdminAuth} from "@/components/auth/require-auth"
import { AdminUsersProvider } from '@/contexts/admin-users-context';
import { RequireAdminAuth } from '@/components/auth/require-auth';
export default function AdminLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<RequireAdminAuth>
<AdminUsersProvider>
{children}
</AdminUsersProvider>
<AdminUsersProvider>{children}</AdminUsersProvider>
</RequireAdminAuth>
)
}
);
}
@@ -14,209 +14,282 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'use client';
import {useCallback, useEffect, useState} from "react"
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react"
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
import { useCallback, useEffect, useState } from 'react';
import {
BarChart3,
Globe,
RefreshCw,
TrendingUp,
Users,
XCircle,
} from 'lucide-react';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import services from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart"
import {Spinner} from "@/components/ui/spinner"
import services from '@/lib/services';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { Spinner } from '@/components/ui/spinner';
interface TrendData {
date: string
count: number
date: string;
count: number;
}
interface BrowserData {
browser: string
count: number
browser: string;
count: number;
}
interface TopUserData {
user_id: string
username: string
nickname: string
count: number
user_id: string;
username: string;
nickname: string;
count: number;
}
const chartConfig = {
count: {
label: "请求量",
color: "hsl(var(--primary))",
label: '请求量',
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig
} satisfies ChartConfig;
// Format YYYY-MM-DD to MM/DD
function formatDateLabel(dateStr: string) {
if (!dateStr || dateStr.length < 10) return dateStr
const parts = dateStr.split("-")
if (!dateStr || dateStr.length < 10) return dateStr;
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parts[1]}/${parts[2]}`
return `${parts[1]}/${parts[2]}`;
}
return dateStr
return dateStr;
}
export function AccessAnalytics() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
const [trend, setTrend] = useState<TrendData[]>([])
const [browsers, setBrowsers] = useState<BrowserData[]>([])
const [topUsers, setTopUsers] = useState<TopUserData[]>([])
const [trend, setTrend] = useState<TrendData[]>([]);
const [browsers, setBrowsers] = useState<BrowserData[]>([]);
const [topUsers, setTopUsers] = useState<TopUserData[]>([]);
const fetchAnalytics = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await services.adminLog.getLogsAnalytics()
setLoading(true);
setError(null);
const data = await services.adminLog.getLogsAnalytics();
// Formats the date labels for the X-axis representation
const formattedTrend = (data.trend || []).map(item => ({
const formattedTrend = (data.trend || []).map((item) => ({
...item,
formattedDate: formatDateLabel(item.date)
}))
formattedDate: formatDateLabel(item.date),
}));
setTrend(formattedTrend)
setBrowsers(data.browsers || [])
setTopUsers(data.top_users || [])
setClickhouseDisabled(false)
setTrend(formattedTrend);
setBrowsers(data.browsers || []);
setTopUsers(data.top_users || []);
setClickhouseDisabled(false);
} catch (err) {
const errorInstance = err instanceof Error ? err : new Error("获取数据统计失败")
const errMsg = errorInstance.message || ""
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
setClickhouseDisabled(true)
const errorInstance =
err instanceof Error ? err : new Error('获取数据统计失败');
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true);
} else {
setError(errorInstance)
setError(errorInstance);
}
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchAnalytics()
}, [fetchAnalytics])
fetchAnalytics();
}, [fetchAnalytics]);
const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0)
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0)
const totalBrowserRequests = browsers.reduce(
(sum, item) => sum + item.count,
0,
);
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0);
if (clickhouseDisabled) {
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>
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
<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>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
</p>
</div>
)
);
}
if (error) {
return <ErrorInline error={error} onRetry={fetchAnalytics} />
return <ErrorInline error={error} onRetry={fetchAnalytics} />;
}
if (loading) {
return <LoadingStateWithBorder icon={BarChart3} description="加载访问统计指标中..." />
return (
<LoadingStateWithBorder
icon={BarChart3}
description='加载访问统计指标中...'
/>
);
}
return (
<div className="space-y-6">
<div className='space-y-6'>
{/* Overview Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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 天总请求数</CardTitle>
<TrendingUp className="size-4 text-muted-foreground" />
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
<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 天总请求数
</CardTitle>
<TrendingUp className='size-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">{totalTrendRequests.toLocaleString()}</div>
<p className="text-[10px] text-muted-foreground mt-1">系统记录的所有成功认证的访问总频次</p>
<div className='text-2xl font-bold font-mono'>
{totalTrendRequests.toLocaleString()}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
系统记录的所有成功认证的访问总频次
</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">活跃终端分类数</CardTitle>
<Globe className="size-4 text-muted-foreground" />
<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'>
活跃终端分类数
</CardTitle>
<Globe className='size-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">{browsers.length}</div>
<p className="text-[10px] text-muted-foreground mt-1">在一周内发起请求的浏览器代理大类统计</p>
<div className='text-2xl font-bold font-mono'>
{browsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
在一周内发起请求的浏览器代理大类统计
</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">活跃独立用户数</CardTitle>
<Users className="size-4 text-muted-foreground" />
<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'>
活跃独立用户数
</CardTitle>
<Users className='size-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">{topUsers.length}</div>
<p className="text-[10px] text-muted-foreground mt-1">一周内累计发起高频请求的注册账户总量</p>
<div className='text-2xl font-bold font-mono'>
{topUsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
一周内累计发起高频请求的注册账户总量
</p>
</CardContent>
</Card>
</div>
{/* Access Trend Chart */}
<Card className="bg-card/20 border-border/40">
<CardHeader className="flex flex-row items-center justify-between">
<Card className='bg-card/20 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className="text-base font-bold">一周访问量趋势</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-bold'>
一周访问量趋势
</CardTitle>
<CardDescription className='text-xs'>
展现系统最近 7 天内的每日 API 请求曲线
</CardDescription>
</div>
<Button variant="ghost" size="icon" className="size-8" onClick={fetchAnalytics} disabled={loading}>
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4 text-muted-foreground" />}
<Button
variant='ghost'
size='icon'
className='size-8'
onClick={fetchAnalytics}
disabled={loading}
>
{loading ? (
<Spinner className='size-4' />
) : (
<RefreshCw className='size-4 text-muted-foreground' />
)}
</Button>
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<CardContent className='pl-2 pr-4 pt-2'>
{trend.length === 0 ? (
<EmptyStateWithBorder icon={TrendingUp} description="暂无趋势数据" />
<EmptyStateWithBorder
icon={TrendingUp}
description='暂无趋势数据'
/>
) : (
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="w-full h-full aspect-auto">
<div className='h-[280px] w-full'>
<ChartContainer
config={chartConfig}
className='w-full h-full aspect-auto'
>
<AreaChart
data={trend}
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
<linearGradient id='colorCount' x1='0' y1='0' x2='0' y2='1'>
<stop
offset='5%'
stopColor='var(--color-count)'
stopOpacity={0.3}
/>
<stop
offset='95%'
stopColor='var(--color-count)'
stopOpacity={0.01}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<CartesianGrid strokeDasharray='3 3' vertical={false} />
<XAxis
dataKey="formattedDate"
dataKey='formattedDate'
tickLine={false}
axisLine={false}
dy={10}
className="font-mono"
className='font-mono'
/>
<YAxis
tickLine={false}
axisLine={false}
dx={-10}
className="font-mono"
className='font-mono'
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="count"
stroke="var(--color-count)"
type='monotone'
dataKey='count'
stroke='var(--color-count)'
strokeWidth={2}
fillOpacity={1}
fill="url(#colorCount)"
name="请求次数"
fill='url(#colorCount)'
name='请求次数'
/>
</AreaChart>
</ChartContainer>
@@ -226,46 +299,56 @@ export function AccessAnalytics() {
</Card>
{/* Two Columns for Ranking Statistics */}
<div className="grid gap-6 md:grid-cols-2">
<div className='grid gap-6 md:grid-cols-2'>
{/* Browser Rankings */}
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
<Card className='bg-card/20 border-border/40 flex flex-col h-full'>
<CardHeader>
<CardTitle className="text-base font-bold flex items-center gap-1.5">
<Globe className="size-4.5 text-muted-foreground" />
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<CardContent className='flex-1'>
{browsers.length === 0 ? (
<EmptyStateWithBorder icon={Globe} description="暂无浏览器排行数据" />
<EmptyStateWithBorder
icon={Globe}
description='暂无浏览器排行数据'
/>
) : (
<div className="space-y-4">
<div className='space-y-4'>
{browsers.map((item, index) => {
const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0
const percent =
totalBrowserRequests > 0
? (item.count / totalBrowserRequests) * 100
: 0;
return (
<div key={item.browser} className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-medium">
<span className="flex items-center gap-2">
<Badge variant="outline" className="px-1.5 py-0 h-5 font-mono text-[10px] select-none">
<div key={item.browser} className='space-y-1.5'>
<div className='flex items-center justify-between text-xs font-medium'>
<span className='flex items-center gap-2'>
<Badge
variant='outline'
className='px-1.5 py-0 h-5 font-mono text-[10px] select-none'
>
{index + 1}
</Badge>
<span className="font-semibold">{item.browser}</span>
<span className='font-semibold'>{item.browser}</span>
</span>
<span className="text-muted-foreground font-mono text-xs">
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}%)
<span className='text-muted-foreground font-mono text-xs'>
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}
%)
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
<div
className="h-full bg-primary rounded-full transition-all duration-500"
className='h-full bg-primary rounded-full transition-all duration-500'
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
);
})}
</div>
)}
@@ -273,39 +356,50 @@ export function AccessAnalytics() {
</Card>
{/* Top Users */}
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
<Card className='bg-card/20 border-border/40 flex flex-col h-full'>
<CardHeader>
<CardTitle className="text-base font-bold flex items-center gap-1.5">
<Users className="size-4.5 text-muted-foreground" />
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Users className='size-4.5 text-muted-foreground' />
最活跃的用户排行 (Top 10)
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<CardContent className='flex-1'>
{topUsers.length === 0 ? (
<EmptyStateWithBorder icon={Users} description="暂无活跃用户数据" />
<EmptyStateWithBorder
icon={Users}
description='暂无活跃用户数据'
/>
) : (
<div className="divide-y divide-border/40">
<div className='divide-y divide-border/40'>
{topUsers.map((user) => (
<div key={user.user_id} className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none">
{(user.username || "U").slice(0, 1).toUpperCase()}
<div
key={user.user_id}
className='flex items-center justify-between py-2.5 first:pt-0 last:pb-0'
>
<div className='flex items-center gap-3'>
<div className='flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none'>
{(user.username || 'U').slice(0, 1).toUpperCase()}
</div>
<div className="truncate max-w-[180px] sm:max-w-xs">
<div className="text-xs font-bold leading-tight truncate">
{user.username || "未知"}
<div className='truncate max-w-[180px] sm:max-w-xs'>
<div className='text-xs font-bold leading-tight truncate'>
{user.username || '未知'}
</div>
<div className="text-[10px] text-muted-foreground leading-normal truncate">
{user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id}
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
ID: {user.user_id}
</div>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-bold font-mono">{user.count.toLocaleString()}</div>
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">次请求</div>
<div className='text-right shrink-0'>
<div className='text-xs font-bold font-mono'>
{user.count.toLocaleString()}
</div>
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
次请求
</div>
</div>
</div>
))}
@@ -315,5 +409,5 @@ export function AccessAnalytics() {
</Card>
</div>
</div>
)
);
}
@@ -14,441 +14,540 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'use client';
import {useMemo, useState} from "react"
import {useQuery} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
import {
Activity,
ChevronLeft,
ChevronRight,
Copy,
Eye,
RotateCcw,
Search,
XCircle,
} from 'lucide-react';
import services from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import services from '@/lib/services';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface AccessLog {
id: string
user_id: string
username: string
nickname: string
path: string
method: string
ip: string
user_agent: string
headers: string
status: number
latency: number
created_at: string
id: string;
user_id: string;
username: string;
nickname: string;
path: string;
method: string;
ip: string;
user_agent: string;
headers: string;
status: number;
latency: number;
created_at: string;
}
const PAGE_SIZE = 15
const PAGE_SIZE = 15;
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return format(date, "yyyy-MM-dd HH:mm:ss")
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
function formatLatency(ms: number) {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function statusVariant(status: number) {
if (status >= 500) return "destructive"
if (status >= 400) return "destructive"
if (status >= 300) return "outline"
return "secondary"
if (status >= 500) return 'destructive';
if (status >= 400) return 'destructive';
if (status >= 300) return 'outline';
return 'secondary';
}
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || ""
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
const errMsg = error.message || '';
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
}
export function AccessLogs() {
const [page, setPage] = useState(1)
const [page, setPage] = useState(1);
// 搜索过滤条件
const [usernameFilter, setUsernameFilter] = useState("")
const [pathFilter, setPathFilter] = useState("")
const [startTimeFilter, setStartTimeFilter] = useState("")
const [endTimeFilter, setEndTimeFilter] = useState("")
// 搜索过滤条件
const [usernameFilter, setUsernameFilter] = useState('');
const [pathFilter, setPathFilter] = useState('');
const [startTimeFilter, setStartTimeFilter] = useState('');
const [endTimeFilter, setEndTimeFilter] = useState('');
// 实际提交的搜索条件
const [searchParams, setSearchParams] = useState({
username: "",
path: "",
start_time: "",
end_time: "",
})
// 实际提交的搜索条件
const [searchParams, setSearchParams] = useState({
username: '',
path: '',
start_time: '',
end_time: '',
});
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const logsQuery = useQuery({
queryKey: ["admin", "access-logs", page, searchParams],
queryFn: async () => {
let startISO = ""
if (searchParams.start_time) {
startISO = new Date(searchParams.start_time).toISOString()
}
let endISO = ""
if (searchParams.end_time) {
endISO = new Date(searchParams.end_time).toISOString()
}
const logsQuery = useQuery({
queryKey: ['admin', 'access-logs', page, searchParams],
queryFn: async () => {
let startISO = '';
if (searchParams.start_time) {
startISO = new Date(searchParams.start_time).toISOString();
}
let endISO = '';
if (searchParams.end_time) {
endISO = new Date(searchParams.end_time).toISOString();
}
return services.adminLog.getAccessLogs({
page,
page_size: PAGE_SIZE,
username: searchParams.username || undefined,
path: searchParams.path || undefined,
start_time: startISO || undefined,
end_time: endISO || undefined,
})
},
retry: false,
})
return services.adminLog.getAccessLogs({
page,
page_size: PAGE_SIZE,
username: searchParams.username || undefined,
path: searchParams.path || undefined,
start_time: startISO || undefined,
end_time: endISO || undefined,
});
},
retry: false,
});
const logs = logsQuery.data?.list ?? []
const total = logsQuery.data?.total ?? 0
const loading = logsQuery.isPending || logsQuery.isFetching
const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error)
const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null
const logs = logsQuery.data?.list ?? [];
const total = logsQuery.data?.total ?? 0;
const loading = logsQuery.isPending || logsQuery.isFetching;
const clickhouseDisabled =
logsQuery.isError && isClickhouseDisabledError(logsQuery.error);
const error =
logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null;
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
setSearchParams({
username: usernameFilter.trim(),
path: pathFilter.trim(),
start_time: startTimeFilter,
end_time: endTimeFilter,
})
}
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
setSearchParams({
username: usernameFilter.trim(),
path: pathFilter.trim(),
start_time: startTimeFilter,
end_time: endTimeFilter,
});
};
const handleReset = () => {
setUsernameFilter("")
setPathFilter("")
setStartTimeFilter("")
setEndTimeFilter("")
setPage(1)
setSearchParams({
username: "",
path: "",
start_time: "",
end_time: "",
})
}
const handleReset = () => {
setUsernameFilter('');
setPathFilter('');
setStartTimeFilter('');
setEndTimeFilter('');
setPage(1);
setSearchParams({
username: '',
path: '',
start_time: '',
end_time: '',
});
};
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text)
toast.success(`${subject}已复制到剪贴板`)
}
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text);
toast.success(`${subject}已复制到剪贴板`);
};
// Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return "暂无头部数据"
try {
const parsed = JSON.parse(headersRaw)
return JSON.stringify(parsed, null, 2)
} catch {
return headersRaw
}
}
// Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return '暂无头部数据';
try {
const parsed = JSON.parse(headersRaw);
return JSON.stringify(parsed, null, 2);
} catch {
return headersRaw;
}
};
if (clickhouseDisabled) {
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>
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
</p>
</div>
)
}
if (clickhouseDisabled) {
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>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
</p>
</div>
);
}
if (error) {
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
}
if (error) {
return (
<ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
);
}
return (
<div className="space-y-4">
{/* Filters */}
<form onSubmit={handleSearch} className="grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end">
<div className="grid gap-1.5">
<Label htmlFor="search-user" className="text-xs">用户名</Label>
<Input
id="search-user"
placeholder="输入用户名搜索..."
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="search-path" className="text-xs">接口路径</Label>
<Input
id="search-path"
placeholder="模糊匹配路径..."
value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="search-start" className="text-xs">起始时间</Label>
<Input
id="search-start"
type="datetime-local"
value={startTimeFilter}
onChange={(e) => setStartTimeFilter(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="search-end" className="text-xs">结束时间</Label>
<Input
id="search-end"
type="datetime-local"
value={endTimeFilter}
onChange={(e) => setEndTimeFilter(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" className="h-8 flex-1">
<Search className="size-3.5 mr-1" />
搜索
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleReset} className="h-8 flex-1">
<RotateCcw className="size-3.5 mr-1" />
重置
</Button>
</div>
</form>
return (
<div className='space-y-4'>
{/* Filters */}
<form
onSubmit={handleSearch}
className='grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end'
>
<div className='grid gap-1.5'>
<Label htmlFor='search-user' className='text-xs'>
用户名
</Label>
<Input
id='search-user'
placeholder='输入用户名搜索...'
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
className='h-8 text-xs'
/>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-path' className='text-xs'>
接口路径
</Label>
<Input
id='search-path'
placeholder='模糊匹配路径...'
value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)}
className='h-8 text-xs'
/>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-start' className='text-xs'>
起始时间
</Label>
<Input
id='search-start'
type='datetime-local'
value={startTimeFilter}
onChange={(e) => setStartTimeFilter(e.target.value)}
className='h-8 text-xs'
/>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-end' className='text-xs'>
结束时间
</Label>
<Input
id='search-end'
type='datetime-local'
value={endTimeFilter}
onChange={(e) => setEndTimeFilter(e.target.value)}
className='h-8 text-xs'
/>
</div>
<div className='flex gap-2'>
<Button type='submit' size='sm' className='h-8 flex-1'>
<Search className='size-3.5 mr-1' />
搜索
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleReset}
className='h-8 flex-1'
>
<RotateCcw className='size-3.5 mr-1' />
重置
</Button>
</div>
</form>
{/* Loading Table */}
{loading && logs.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description="加载访问日志中..." />
) : logs.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description="未查询到任何访问日志数据" />
) : (
<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>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id} className="hover:bg-muted/30">
<TableCell>
<Badge variant="outline" className="font-mono font-semibold uppercase text-xs">
{log.method}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs break-all max-w-[280px]">
{log.path}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-xs font-medium">{log.username || "未知用户"}</span>
{log.nickname && (
<span className="text-[10px] text-muted-foreground">({log.nickname})</span>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{log.ip}
</TableCell>
<TableCell>
<Badge variant={statusVariant(log.status)}>
{log.status}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatLatency(log.latency)}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="size-7 hover:bg-muted"
onClick={() => {
setSelectedLog(log)
setDetailOpen(true)
}}
>
<Eye className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Loading Table */}
{loading && logs.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载访问日志中...'
/>
) : logs.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
description='未查询到任何访问日志数据'
/>
) : (
<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>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id} className='hover:bg-muted/30'>
<TableCell>
<Badge
variant='outline'
className='font-mono font-semibold uppercase text-xs'
>
{log.method}
</Badge>
</TableCell>
<TableCell className='font-mono text-xs break-all max-w-[280px]'>
{log.path}
</TableCell>
<TableCell>
<div className='flex flex-col'>
<span className='text-xs font-medium'>
{log.username || '未知用户'}
</span>
{log.nickname && (
<span className='text-[10px] text-muted-foreground'>
({log.nickname})
</span>
)}
</div>
</TableCell>
<TableCell className='font-mono text-xs text-muted-foreground'>
{log.ip}
</TableCell>
<TableCell>
<Badge variant={statusVariant(log.status)}>
{log.status}
</Badge>
</TableCell>
<TableCell className='font-mono text-xs text-muted-foreground'>
{formatLatency(log.latency)}
</TableCell>
<TableCell className='font-mono text-[11px] text-muted-foreground'>
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className='text-center'>
<Button
variant='ghost'
size='icon'
className='size-7 hover:bg-muted'
onClick={() => {
setSelectedLog(log);
setDetailOpen(true);
}}
>
<Eye className='size-3.5' />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Pagination */}
{logs.length > 0 && (
<div className="flex items-center justify-between py-1">
<div className="text-xs text-muted-foreground">
共 {total} 条记录,当前第 {page}/{totalPages} 页
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
className="h-8 text-xs"
>
<ChevronLeft className="size-3.5 mr-1" />
上一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
className="h-8 text-xs"
>
下一页
<ChevronRight className="size-3.5 ml-1" />
</Button>
</div>
</div>
)}
{/* Pagination */}
{logs.length > 0 && (
<div className='flex items-center justify-between py-1'>
<div className='text-xs text-muted-foreground'>
共 {total} 条记录,当前第 {page}/{totalPages} 页
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
className='h-8 text-xs'
>
<ChevronLeft className='size-3.5 mr-1' />
上一页
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
className='h-8 text-xs'
>
下一页
<ChevronRight className='size-3.5 ml-1' />
</Button>
</div>
</div>
)}
{/* Detail Drawer */}
<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>
<SheetDescription className="text-xs">
访问 ID: {selectedLog?.id}
</SheetDescription>
</SheetHeader>
{/* Detail Drawer */}
<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>
<SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id}
</SheetDescription>
</SheetHeader>
{selectedLog ? (
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Summary Details */}
<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">请求方法</div>
<div className="mt-1 font-mono text-sm font-bold uppercase">{selectedLog.method}</div>
</div>
<div className="rounded-lg border p-3 bg-muted/10">
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">响应状态</div>
<div className="mt-1">
<Badge variant={statusVariant(selectedLog.status)}>
{selectedLog.status}
</Badge>
</div>
</div>
<div className="rounded-lg border p-3 bg-muted/10">
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">耗时</div>
<div className="mt-1 font-mono text-sm">{formatLatency(selectedLog.latency)}</div>
</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 地址</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">用户</div>
<div className="mt-1 text-sm font-medium">
{selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"}
<span className="block font-mono text-[10px] text-muted-foreground mt-0.5">ID: {selectedLog.user_id}</span>
</div>
</div>
</div>
{selectedLog ? (
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-5'>
{/* Summary Details */}
<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'>
请求方法
</div>
<div className='mt-1 font-mono text-sm font-bold uppercase'>
{selectedLog.method}
</div>
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
响应状态
</div>
<div className='mt-1'>
<Badge variant={statusVariant(selectedLog.status)}>
{selectedLog.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
耗时
</div>
<div className='mt-1 font-mono text-sm'>
{formatLatency(selectedLog.latency)}
</div>
</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 地址
</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'>
用户
</div>
<div className='mt-1 text-sm font-medium'>
{selectedLog.username
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
: '未知/游客'}
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
ID: {selectedLog.user_id}
</span>
</div>
</div>
</div>
{/* Path */}
<div className="grid gap-2">
<Label className="text-xs font-bold text-muted-foreground">请求路径</Label>
<div className="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
{selectedLog.path}
</div>
</div>
{/* Path */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求路径
</Label>
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
{selectedLog.path}
</div>
</div>
{/* Request Time */}
<div className="grid gap-2">
<Label className="text-xs font-bold text-muted-foreground">请求时间</Label>
<div className="font-mono text-xs">{formatDateTime(selectedLog.created_at)}</div>
</div>
{/* Request Time */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求时间
</Label>
<div className='font-mono text-xs'>
{formatDateTime(selectedLog.created_at)}
</div>
</div>
{/* User Agent */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold text-muted-foreground">User-Agent</Label>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => copyToClipboard(selectedLog.user_agent, "User-Agent")}
title="复制 User-Agent"
>
<Copy className="size-3" />
</Button>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed">
{selectedLog.user_agent}
</div>
</div>
{/* User Agent */}
<div className='grid gap-2'>
<div className='flex items-center justify-between'>
<Label className='text-xs font-bold text-muted-foreground'>
User-Agent
</Label>
<Button
variant='ghost'
size='icon'
className='size-6 text-muted-foreground hover:text-foreground'
onClick={() =>
copyToClipboard(selectedLog.user_agent, 'User-Agent')
}
title='复制 User-Agent'
>
<Copy className='size-3' />
</Button>
</div>
<div className='rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed'>
{selectedLog.user_agent}
</div>
</div>
{/* Headers */}
<div className="grid gap-2 flex-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold text-muted-foreground">Request Headers</Label>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => copyToClipboard(getPrettyHeaders(selectedLog.headers), "Headers")}
title="复制 Headers"
>
<Copy className="size-3" />
</Button>
</div>
<pre className="overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]">
{getPrettyHeaders(selectedLog.headers)}
</pre>
</div>
</div>
) : (
<div className="flex-grow flex items-center justify-center">
<EmptyStateWithBorder icon={Activity} description="未选中日志明细" />
</div>
)}
</SheetContent>
</Sheet>
</div>
)
}
{/* Headers */}
<div className='grid gap-2 flex-1'>
<div className='flex items-center justify-between'>
<Label className='text-xs font-bold text-muted-foreground'>
Request Headers
</Label>
<Button
variant='ghost'
size='icon'
className='size-6 text-muted-foreground hover:text-foreground'
onClick={() =>
copyToClipboard(
getPrettyHeaders(selectedLog.headers),
'Headers',
)
}
title='复制 Headers'
>
<Copy className='size-3' />
</Button>
</div>
<pre className='overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]'>
{getPrettyHeaders(selectedLog.headers)}
</pre>
</div>
</div>
) : (
<div className='flex-grow flex items-center justify-center'>
<EmptyStateWithBorder
icon={Activity}
description='未选中日志明细'
/>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -14,92 +14,103 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'use client';
import {memo, useCallback, useEffect, useRef, useState} from "react"
import {useVirtualizer} from "@tanstack/react-virtual"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { toast } from 'sonner';
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
import services from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Button} from "@/components/ui/button"
import services from '@/lib/services';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
interface LogEntry {
index: number
data: string
index: number;
data: string;
}
// Distance (px) from bottom to treat as "at bottom"
const BOTTOM_THRESHOLD = 40
const LOG_LINE_ESTIMATE_PX = 20
const LOG_VIRTUAL_OVERSCAN = 24
const BOTTOM_THRESHOLD = 40;
const LOG_LINE_ESTIMATE_PX = 20;
const LOG_VIRTUAL_OVERSCAN = 24;
const LogLine = memo(function LogLine({data}: {data: string}) {
const level = parseLogLevel(data)
const color = level === "error"
? "text-red-400"
: level === "warn"
? "text-yellow-400"
: level === "debug"
? "text-gray-500"
: "text-gray-300"
const LogLine = memo(function LogLine({ data }: { data: string }) {
const level = parseLogLevel(data);
const color =
level === 'error'
? 'text-red-400'
: level === 'warn'
? 'text-yellow-400'
: level === 'debug'
? 'text-gray-500'
: 'text-gray-300';
return (
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
<div
className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}
>
{data}
</div>
)
})
);
});
function getApiBaseUrl(): string {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
if (base.startsWith("http")) return base
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
if (base.startsWith('http')) return base;
// Relative URL fallback
const proto = window.location.protocol
const host = window.location.host
return `${proto}//${host}${base}`
const proto = window.location.protocol;
const host = window.location.host;
return `${proto}//${host}${base}`;
}
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
function buildWsUrl(): string {
const base = getApiBaseUrl()
const wsBase = base.replace(/^http/, "ws")
return `${wsBase}/api/v1/admin/logs/ws`
const base = getApiBaseUrl();
const wsBase = base.replace(/^http/, 'ws');
return `${wsBase}/api/v1/admin/logs/ws`;
}
function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" {
const lower = line.toLowerCase()
if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error"
if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn"
if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug"
if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info"
return "unknown"
function parseLogLevel(
line: string,
): 'debug' | 'info' | 'warn' | 'error' | 'unknown' {
const lower = line.toLowerCase();
if (lower.includes('"level":"error"') || lower.includes('level=error'))
return 'error';
if (lower.includes('"level":"warn"') || lower.includes('level=warn'))
return 'warn';
if (lower.includes('"level":"debug"') || lower.includes('level=debug'))
return 'debug';
if (lower.includes('"level":"info"') || lower.includes('level=info'))
return 'info';
return 'unknown';
}
export function AppLogs() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([])
const [hasMore, setHasMore] = useState(false)
const [nextCursor, setNextCursor] = useState(0)
const [loadingMore, setLoadingMore] = useState(false)
const [logs, setLogs] = useState<LogEntry[]>([]);
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
// autoScroll = true → new logs auto-scroll to bottom
const [autoScroll, setAutoScroll] = useState(true)
const [autoScroll, setAutoScroll] = useState(true);
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const autoScrollRef = useRef(autoScroll)
const isUserScrolling = useRef(false)
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const autoScrollRef = useRef(autoScroll);
const isUserScrolling = useRef(false);
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
useEffect(() => {
autoScrollRef.current = autoScroll;
}, [autoScroll]);
const rowVirtualizer = useVirtualizer({
count: logs.length,
@@ -107,200 +118,222 @@ export function AppLogs() {
estimateSize: () => LOG_LINE_ESTIMATE_PX,
overscan: LOG_VIRTUAL_OVERSCAN,
measureElement: (element) => element.getBoundingClientRect().height,
})
});
// ---- Scroll detection ------------------------------------------------
const handleScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
const el = containerRef.current;
if (!el) return;
if (!isUserScrolling.current) return
if (!isUserScrolling.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD;
if (atBottom && !autoScrollRef.current) {
setAutoScroll(true)
setAutoScroll(true);
} else if (!atBottom && autoScrollRef.current) {
setAutoScroll(false)
setAutoScroll(false);
}
}, [])
}, []);
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
const handleWheel = useCallback(() => {
isUserScrolling.current = true;
}, []);
const handleTouchStart = useCallback(() => {
isUserScrolling.current = true;
}, []);
useEffect(() => {
const el = containerRef.current
if (!el) return
const el = containerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout>
let timer: ReturnType<typeof setTimeout>;
const onScrollEnd = () => {
clearTimeout(timer)
timer = setTimeout(() => { isUserScrolling.current = false }, 150)
}
el.addEventListener("scroll", onScrollEnd, { passive: true })
clearTimeout(timer);
timer = setTimeout(() => {
isUserScrolling.current = false;
}, 150);
};
el.addEventListener('scroll', onScrollEnd, { passive: true });
return () => {
el.removeEventListener("scroll", onScrollEnd)
clearTimeout(timer)
}
}, [])
el.removeEventListener('scroll', onScrollEnd);
clearTimeout(timer);
};
}, []);
// ---- Auto-scroll to bottom when new logs arrive ----------------------
useEffect(() => {
if (!autoScroll || !containerRef.current) return
isUserScrolling.current = false
const el = containerRef.current
if (!autoScroll || !containerRef.current) return;
isUserScrolling.current = false;
const el = containerRef.current;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight
})
}, [logs, autoScroll])
el.scrollTop = el.scrollHeight;
});
}, [logs, autoScroll]);
// ---- Data fetching ---------------------------------------------------
const fetchLogs = useCallback(async (cursor: number = 0) => {
try {
return await services.adminLog.getLogs(cursor)
return await services.adminLog.getLogs(cursor);
} catch (err) {
throw err instanceof Error ? err : new Error("获取日志失败")
throw err instanceof Error ? err : new Error('获取日志失败');
}
}, [])
}, []);
const loadHistory = useCallback(async (cursor: number = 0) => {
const isInitial = cursor === 0
if (isInitial) {
setLoading(true)
setError(null)
} else {
setLoadingMore(true)
}
try {
const data = await fetchLogs(cursor)
const loadHistory = useCallback(
async (cursor: number = 0) => {
const isInitial = cursor === 0;
if (isInitial) {
setLogs(data.lines || [])
setLoading(true);
setError(null);
} else {
const el = containerRef.current
const previousScrollHeight = el?.scrollHeight ?? 0
setLogs(prev => [...(data.lines || []), ...prev])
setLoadingMore(true);
}
requestAnimationFrame(() => {
if (!el) return
isUserScrolling.current = false
el.scrollTop = el.scrollHeight - previousScrollHeight
})
try {
const data = await fetchLogs(cursor);
if (isInitial) {
setLogs(data.lines || []);
} else {
const el = containerRef.current;
const previousScrollHeight = el?.scrollHeight ?? 0;
setLogs((prev) => [...(data.lines || []), ...prev]);
requestAnimationFrame(() => {
if (!el) return;
isUserScrolling.current = false;
el.scrollTop = el.scrollHeight - previousScrollHeight;
});
}
setHasMore(data.has_more);
setNextCursor(data.next_cursor);
} catch (err) {
if (isInitial) {
setError(err instanceof Error ? err : new Error('获取日志失败'));
} else {
toast.error('加载更早日志失败');
}
} finally {
if (isInitial) setLoading(false);
else setLoadingMore(false);
}
setHasMore(data.has_more)
setNextCursor(data.next_cursor)
} catch (err) {
if (isInitial) {
setError(err instanceof Error ? err : new Error("获取日志失败"))
} else {
toast.error("加载更早日志失败")
}
} finally {
if (isInitial) setLoading(false)
else setLoadingMore(false)
}
}, [fetchLogs])
},
[fetchLogs],
);
// ---- WebSocket -------------------------------------------------------
const connectWs = useCallback(() => {
if (wsRef.current) wsRef.current.close()
if (wsRef.current) wsRef.current.close();
const ws = new WebSocket(buildWsUrl())
wsRef.current = ws
const ws = new WebSocket(buildWsUrl());
wsRef.current = ws;
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === "log" && msg.data) {
const entry: LogEntry = msg.data
setLogs(prev => {
const next = [...prev, entry]
return next.length > 2000 ? next.slice(-2000) : next
})
const msg = JSON.parse(event.data);
if (msg.type === 'log' && msg.data) {
const entry: LogEntry = msg.data;
setLogs((prev) => {
const next = [...prev, entry];
return next.length > 2000 ? next.slice(-2000) : next;
});
}
} catch { /* ignore */ }
}
} catch {
/* ignore */
}
};
ws.onclose = () => { wsRef.current = null }
}, [])
ws.onclose = () => {
wsRef.current = null;
};
}, []);
// ---- Initialize ------------------------------------------------------
useEffect(() => {
loadHistory(0).then(() => connectWs())
loadHistory(0).then(() => connectWs());
return () => {
wsRef.current?.close()
wsRef.current = null
}
wsRef.current?.close();
wsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, []);
// ---- Actions ---------------------------------------------------------
const scrollToBottom = useCallback(() => {
setAutoScroll(true)
setAutoScroll(true);
requestAnimationFrame(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
})
}, [])
});
}, []);
const handleLoadMore = useCallback(() => {
if (nextCursor > 0) loadHistory(nextCursor)
}, [nextCursor, loadHistory])
if (nextCursor > 0) loadHistory(nextCursor);
}, [nextCursor, loadHistory]);
// ---- Render ----------------------------------------------------------
if (loading) return <LoadingStateWithBorder />
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
if (loading) return <LoadingStateWithBorder />;
if (error)
return <ErrorInline error={error} onRetry={() => loadHistory(0)} />;
return (
<div className="flex flex-col h-full relative">
<div className='flex flex-col h-full relative'>
{/* Log viewer — fixed height, scrollable */}
<div
ref={containerRef}
onScroll={handleScroll}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
className='h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative'
>
{/* Load older logs */}
{hasMore && (
<div className="sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm">
<div className='sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm'>
<Button
variant="ghost"
size="sm"
variant='ghost'
size='sm'
onClick={handleLoadMore}
disabled={loadingMore}
className="text-muted-foreground hover:text-foreground h-7 text-xs"
className='text-muted-foreground hover:text-foreground h-7 text-xs'
>
{loadingMore
? <><Loader2 className="size-3 mr-1.5 animate-spin" />加载中...</>
: <><ChevronUp className="size-3 mr-1.5" />加载更早日志</>
}
{loadingMore ? (
<>
<Loader2 className='size-3 mr-1.5 animate-spin' />
加载中...
</>
) : (
<>
<ChevronUp className='size-3 mr-1.5' />
加载更早日志
</>
)}
</Button>
</div>
)}
{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'>暂无日志</div>
) : (
<div
className="relative px-3 py-3"
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
className='relative px-3 py-3'
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const entry = logs[virtualRow.index]
if (!entry) return null
const entry = logs[virtualRow.index];
if (!entry) return null;
return (
<div
key={entry.index}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute top-0 left-0 w-full"
style={{transform: `translateY(${virtualRow.start}px)`}}
className='absolute top-0 left-0 w-full'
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<LogLine data={entry.data} />
</div>
)
);
})}
</div>
)}
@@ -308,18 +341,18 @@ export function AppLogs() {
{/* Floating "back to latest" button */}
{!autoScroll && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
<div className='absolute bottom-6 left-1/2 -translate-x-1/2 z-20'>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
onClick={scrollToBottom}
className="shadow-lg bg-background/80 backdrop-blur-sm border-border/50"
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
>
<ArrowDown className="size-3.5 mr-1.5" />
<ArrowDown className='size-3.5 mr-1.5' />
回到最新
</Button>
</div>
)}
</div>
)
);
}
+39 -29
View File
@@ -1,66 +1,76 @@
"use client"
'use client';
import dynamic from "next/dynamic"
import {Activity, BarChart3, Terminal} from "lucide-react"
import dynamic from 'next/dynamic';
import { Activity, BarChart3, Terminal } from 'lucide-react';
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
const tabFallback = (
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
)
<div className='h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20' />
);
const AccessAnalytics = dynamic(
() => import("./components/access-analytics").then((mod) => mod.AccessAnalytics),
() =>
import('./components/access-analytics').then((mod) => mod.AccessAnalytics),
{ loading: () => tabFallback },
)
);
const AccessLogs = dynamic(
() => import("./components/access-logs").then((mod) => mod.AccessLogs),
() => import('./components/access-logs').then((mod) => mod.AccessLogs),
{ loading: () => tabFallback },
)
);
const AppLogs = dynamic(
() => import("./components/app-logs").then((mod) => mod.AppLogs),
() => import('./components/app-logs').then((mod) => mod.AppLogs),
{ loading: () => tabFallback },
)
);
export function LogsPageClient() {
return (
<div className="flex flex-col h-full space-y-6 py-6">
<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 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'>系统日志</h1>
</div>
</div>
{/* Tabs Layout */}
<Tabs defaultValue="analytics" className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="analytics" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
<BarChart3 className="size-3.5" />
<Tabs defaultValue='analytics' className='w-full'>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='analytics'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<BarChart3 className='size-3.5' />
访问分析
</TabsTrigger>
<TabsTrigger value="access" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
<Activity className="size-3.5" />
<TabsTrigger
value='access'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Activity className='size-3.5' />
用户访问日志
</TabsTrigger>
<TabsTrigger value="app" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
<Terminal className="size-3.5" />
<TabsTrigger
value='app'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Terminal className='size-3.5' />
应用运行日志
</TabsTrigger>
</TabsList>
<TabsContent value="analytics" className="mt-0 outline-none flex-1">
<TabsContent value='analytics' className='mt-0 outline-none flex-1'>
<AccessAnalytics />
</TabsContent>
<TabsContent value="access" className="mt-0 outline-none flex-1">
<TabsContent value='access' className='mt-0 outline-none flex-1'>
<AccessLogs />
</TabsContent>
<TabsContent value="app" className="mt-0 outline-none flex-1">
<TabsContent value='app' className='mt-0 outline-none flex-1'>
<AppLogs />
</TabsContent>
</Tabs>
</div>
)
}
);
}
+3 -3
View File
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {LogsPageClient} from "./page-client"
import { LogsPageClient } from './page-client';
export default function LogsPage() {
return <LogsPageClient />
}
return <LogsPageClient />;
}
File diff suppressed because it is too large Load Diff
@@ -1,21 +1,40 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {useQuery} from "@tanstack/react-query"
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ChevronLeft,
ChevronRight,
History,
RefreshCw,
Search,
} from 'lucide-react';
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {cn} from "@/lib/utils"
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { cn } from '@/lib/utils';
import {
Dialog,
@@ -24,156 +43,205 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
import {PushService} from "@/lib/services/push"
import type {PushHistory} from "@/lib/services/push"
import type { PushHistory } from '@/lib/services/push';
import { PushService } from '@/lib/services/push';
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
function getLevelBadgeVariant(
level: string,
): 'outline' | 'secondary' | 'destructive' | 'default' {
switch (level) {
case "CRITICAL":
return "destructive"
case "IMPORTANT":
return "default"
case 'CRITICAL':
return 'destructive';
case 'IMPORTANT':
return 'default';
default:
return "outline"
return 'outline';
}
}
export function HistoriesTab() {
const [historyPage, setHistoryPage] = React.useState(1)
const [historySearch, setHistorySearch] = React.useState("")
const [historyStatus, setHistoryStatus] = React.useState("all")
const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all');
const [detailOpen, setDetailOpen] = React.useState(false)
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
const [detailOpen, setDetailOpen] = React.useState(false);
const [selectedHistory, setSelectedHistory] =
React.useState<PushHistory | null>(null);
const handleRowClick = (hist: PushHistory) => {
setSelectedHistory(hist)
setDetailOpen(true)
}
setSelectedHistory(hist);
setDetailOpen(true);
};
const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryKey: [
'admin',
'push-histories',
historyPage,
historySearch,
historyStatus,
],
queryFn: () =>
PushService.listHistories({
page: historyPage,
page_size: 10,
event_key: historySearch || undefined,
status: historyStatus === "all" ? undefined : historyStatus,
status: historyStatus === 'all' ? undefined : historyStatus,
}),
})
});
return (
<div className="pt-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<div className='pt-4 space-y-4'>
<div className='flex flex-col sm:flex-row gap-2'>
<div className='relative flex-1'>
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input
type="text"
placeholder="输入事件键过滤..."
className="pl-8 text-xs h-9"
type='text'
placeholder='输入事件键过滤...'
className='pl-8 text-xs h-9'
value={historySearch}
onChange={e => {
setHistorySearch(e.target.value)
setHistoryPage(1)
onChange={(e) => {
setHistorySearch(e.target.value);
setHistoryPage(1);
}}
/>
</div>
<div className="w-[150px]">
<div className='w-[150px]'>
<Select
value={historyStatus}
onValueChange={val => {
setHistoryStatus(val)
setHistoryPage(1)
onValueChange={(val) => {
setHistoryStatus(val);
setHistoryPage(1);
}}
>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="发送状态" />
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
<SelectItem value='all' className='text-xs'>
全部状态
</SelectItem>
<SelectItem value='success' className='text-xs'>
发送成功
</SelectItem>
<SelectItem value='failed' className='text-xs'>
发送失败
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
variant='outline'
size='icon'
className='h-9 w-9 shrink-0'
onClick={() => historiesQuery.refetch()}
>
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
<RefreshCw
className={cn(
'size-3.5',
historiesQuery.isFetching && 'animate-spin',
)}
/>
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<div className='border rounded-lg overflow-hidden'>
<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>
<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>
</TableRow>
</TableHeader>
<TableBody>
{historiesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
<TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder
icon={History}
description='加载推送历史记录中...'
className='border-0 shadow-none'
/>
</TableCell>
</TableRow>
) : historiesQuery.isError ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
<TableCell colSpan={7} className='h-32'>
<ErrorInline
error={historiesQuery.error}
onRetry={() => historiesQuery.refetch()}
className='justify-center'
/>
</TableCell>
</TableRow>
) : (historiesQuery.data?.results ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
<TableCell
colSpan={7}
className='h-32 text-center text-xs text-muted-foreground'
>
无推送历史数据
</TableCell>
</TableRow>
) : (
(historiesQuery.data?.results ?? []).map(hist => (
(historiesQuery.data?.results ?? []).map((hist) => (
<TableRow
key={hist.id}
className="hover:bg-muted/10 cursor-pointer transition-colors"
className='hover:bg-muted/10 cursor-pointer transition-colors'
onClick={() => handleRowClick(hist)}
>
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
{hist.channel === "email" ? "邮件" : hist.channel}
<TableCell className='text-xs font-mono font-medium'>
{hist.event_key}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
title={hist.target}
>
{hist.target}
</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
<TableCell className="text-xs">
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
<TableCell
className='text-xs max-w-[180px] truncate'
title={hist.content}
>
{hist.title}
</TableCell>
<TableCell className='text-xs'>
<Badge
variant={getLevelBadgeVariant(hist.level)}
className='text-[10px] font-semibold'
>
{hist.level}
</Badge>
</TableCell>
<TableCell className="text-xs">
<TableCell className='text-xs'>
<Badge
variant={hist.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold"
variant={
hist.status === 'success' ? 'secondary' : 'destructive'
}
className='text-[10px] font-semibold'
>
{hist.status === "success" ? "成功" : "失败"}
{hist.status === 'success' ? '成功' : '失败'}
</Badge>
{hist.status !== "success" && hist.error_msg && (
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
{hist.status !== 'success' && hist.error_msg && (
<div
className='text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1'
title={hist.error_msg}
>
{hist.error_msg}
</div>
)}
</TableCell>
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
<TableCell className='text-[11px] text-muted-foreground whitespace-nowrap'>
{new Date(hist.created_at).toLocaleString()}
</TableCell>
</TableRow>
@@ -184,27 +252,27 @@ export function HistoriesTab() {
</div>
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className="flex justify-between items-center text-xs text-muted-foreground">
<div className='flex justify-between items-center text-xs text-muted-foreground'>
<span>共 {historiesQuery.data.total} 条历史记录</span>
<div className="flex items-center gap-1.5">
<div className='flex items-center gap-1.5'>
<Button
variant="outline"
size="icon"
variant='outline'
size='icon'
disabled={historyPage === 1}
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
className="h-8 w-8"
onClick={() => setHistoryPage((p) => Math.max(1, p - 1))}
className='h-8 w-8'
>
<ChevronLeft className="size-4" />
<ChevronLeft className='size-4' />
</Button>
<span className="text-xs font-medium px-2">{historyPage}</span>
<span className='text-xs font-medium px-2'>{historyPage}</span>
<Button
variant="outline"
size="icon"
variant='outline'
size='icon'
disabled={historyPage * 10 >= historiesQuery.data.total}
onClick={() => setHistoryPage(p => p + 1)}
className="h-8 w-8"
onClick={() => setHistoryPage((p) => p + 1)}
className='h-8 w-8'
>
<ChevronRight className="size-4" />
<ChevronRight className='size-4' />
</Button>
</div>
</div>
@@ -212,10 +280,10 @@ export function HistoriesTab() {
{/* ==================== 对话框:推送详情 ==================== */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="size-5 text-primary" />
<DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' />
推送通知详情
</DialogTitle>
<DialogDescription>
@@ -224,76 +292,109 @@ export function HistoriesTab() {
</DialogHeader>
{selectedHistory && (
<div className="space-y-4 py-4 text-xs">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">事件标识</span>
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
<div className='space-y-4 py-4 text-xs'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
事件标识
</span>
<div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送渠道</span>
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送渠道
</span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email'
? '邮件推送'
: selectedHistory.channel}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">推送目标</span>
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
推送目标
</span>
<div
className='font-mono bg-muted/40 p-2 rounded border truncate'
title={selectedHistory.target}
>
{selectedHistory.target}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送时间</span>
<div className="bg-muted/40 p-2 rounded border">
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送时间
</span>
<div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知等级</span>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知等级
</span>
<div>
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
<Badge
variant={getLevelBadgeVariant(selectedHistory.level)}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.level}
</Badge>
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送状态</span>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送状态
</span>
<div>
<Badge
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold py-0.5 px-2"
variant={
selectedHistory.status === 'success'
? 'secondary'
: 'destructive'
}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.status === "success" ? "成功" : "失败"}
{selectedHistory.status === 'success' ? '成功' : '失败'}
</Badge>
</div>
</div>
</div>
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
<div className="space-y-1.5">
<span className="font-semibold text-destructive block">失败原因</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}
{selectedHistory.status === 'failed' &&
selectedHistory.error_msg && (
<div className='space-y-1.5'>
<span className='font-semibold text-destructive block'>
失败原因
</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}
</div>
</div>
</div>
)}
)}
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知标题</span>
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知标题
</span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知内容</span>
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知内容
</span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content}
</div>
</div>
@@ -301,12 +402,17 @@ export function HistoriesTab() {
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
<Button
variant='outline'
size='sm'
onClick={() => setDetailOpen(false)}
className='h-9 text-xs'
>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}
File diff suppressed because it is too large Load Diff
+37 -26
View File
@@ -1,65 +1,76 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import * as React from "react"
import {motion} from "motion/react"
import {Bell, History, Layers, Settings} from "lucide-react"
import * as React from 'react';
import { motion } from 'motion/react';
import { Bell, History, Layers, Settings } from 'lucide-react';
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {EventsTab} from "./components/events-tab"
import {HistoriesTab} from "./components/histories-tab"
import {SettingsTab} from "./components/settings-tab"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EventsTab } from './components/events-tab';
import { HistoriesTab } from './components/histories-tab';
import { SettingsTab } from './components/settings-tab';
export default function PushAdminPage() {
const [activeTab, setActiveTab] = React.useState("events")
const [activeTab, setActiveTab] = React.useState('events');
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="w-full py-6 space-y-6"
transition={{ duration: 0.3, ease: 'easeOut' }}
className='w-full py-6 space-y-6'
>
<div className="flex items-center gap-2">
<Bell className="size-5 text-primary" />
<div className='flex items-center gap-2'>
<Bell className='size-5 text-primary' />
<div>
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
通知推送管理
</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8">
<TabsTrigger value="events" className="px-0 pb-2 text-xs font-semibold">
<Layers className="size-3.5 mr-1" />
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
<TabsList variant='line' className='w-fit inline-flex gap-8'>
<TabsTrigger
value='events'
className='px-0 pb-2 text-xs font-semibold'
>
<Layers className='size-3.5 mr-1' />
事件管理
</TabsTrigger>
<TabsTrigger value="histories" className="px-0 pb-2 text-xs font-semibold">
<History className="size-3.5 mr-1" />
<TabsTrigger
value='histories'
className='px-0 pb-2 text-xs font-semibold'
>
<History className='size-3.5 mr-1' />
通知历史
</TabsTrigger>
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
<Settings className="size-3.5 mr-1" />
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
<Settings className='size-3.5 mr-1' />
通道管理与设置
</TabsTrigger>
</TabsList>
{/* ==================== 1. 事件管理 TAB ==================== */}
<TabsContent value="events" className="focus-visible:outline-none">
<TabsContent value='events' className='focus-visible:outline-none'>
<EventsTab />
</TabsContent>
{/* ==================== 2. 通知历史 TAB ==================== */}
<TabsContent value="histories" className="focus-visible:outline-none">
<TabsContent value='histories' className='focus-visible:outline-none'>
<HistoriesTab />
</TabsContent>
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
<TabsContent value="settings" className="focus-visible:outline-none">
<TabsContent value='settings' className='focus-visible:outline-none'>
<SettingsTab />
</TabsContent>
</Tabs>
</motion.div>
)
);
}
@@ -1,206 +1,242 @@
"use client"
'use client';
import * as React from "react"
import {useCallback, useEffect, useRef, useState} from "react"
import {toast} from "sonner"
import {Activity, Cpu, Database, HardDrive, Layers, RefreshCw} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Badge} from "@/components/ui/badge"
import {Progress} from "@/components/ui/progress"
import {Skeleton} from "@/components/ui/skeleton"
import type {SystemStatus} from "@/lib/services/admin"
import services from "@/lib/services"
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
Activity,
Cpu,
Database,
HardDrive,
Layers,
RefreshCw,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import type { SystemStatus } from '@/lib/services/admin';
import services from '@/lib/services';
/**
* 格式化数字,每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, ',');
};
/**
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const [status, setStatus] = useState<SystemStatus | null>(null)
const [loading, setLoading] = useState(true)
const [wavelet, setWavelet] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [intervalTime, setIntervalTime] = useState('5000'); // 默认5秒
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const prevStatusRef = useRef<SystemStatus | null>(null)
const [changedFields, setChangedFields] = useState<Record<string, boolean>>({})
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const prevStatusRef = useRef<SystemStatus | null>(null);
const [changedFields, setChangedFields] = useState<Record<string, boolean>>(
{},
);
// 获取状态数据
const fetchStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setWavelet(true)
if (!isSilent) setWavelet(true);
try {
const data = await services.adminStatus.getSystemStatus()
const data = await services.adminStatus.getSystemStatus();
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {}
const changes: Record<string, boolean> = {};
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus
const k = key as keyof SystemStatus;
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true
changes[k] = true;
}
})
setChangedFields(changes)
});
setChangedFields(changes);
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({})
}, 1000)
setChangedFields({});
}, 1000);
}
setStatus(data)
prevStatusRef.current = data
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error("获取系统状态失败", {
description: err instanceof Error ? err.message : "请求时发生未知错误"
})
toast.error('获取系统状态失败', {
description: err instanceof Error ? err.message : '请求时发生未知错误',
});
} finally {
setLoading(false)
setWavelet(false)
setLoading(false);
setWavelet(false);
}
}, [])
}, []);
// 轮询逻辑
useEffect(() => {
// 首次加载
fetchStatus()
}, [fetchStatus])
fetchStatus();
}, [fetchStatus]);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
clearInterval(intervalRef.current);
}
if (autoRefresh) {
const time = parseInt(intervalTime, 10)
const time = parseInt(intervalTime, 10);
intervalRef.current = setInterval(() => {
fetchStatus(true)
}, time)
fetchStatus(true);
}, time);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
clearInterval(intervalRef.current);
}
}
}, [autoRefresh, intervalTime, fetchStatus])
};
}, [autoRefresh, intervalTime, fetchStatus]);
// 计算内存分配百分比 (Alloc / Sys)
const getMemoryUsagePercent = () => {
if (!status) return 0
if (!status) return 0;
const parseSizeToBytes = (sizeStr: string): number => {
const parts = sizeStr.split(" ")
if (parts.length < 2) return 0
const value = parseFloat(parts[0])
const unit = parts[1].toLowerCase()
const parts = sizeStr.split(' ');
if (parts.length < 2) return 0;
const value = parseFloat(parts[0]);
const unit = parts[1].toLowerCase();
if (unit.startsWith("gib")) return value * 1024 * 1024 * 1024
if (unit.startsWith("mib")) return value * 1024 * 1024
if (unit.startsWith("kib")) return value * 1024
return value
}
if (unit.startsWith('gib')) return value * 1024 * 1024 * 1024;
if (unit.startsWith('mib')) return value * 1024 * 1024;
if (unit.startsWith('kib')) return value * 1024;
return value;
};
const alloc = parseSizeToBytes(status.alloc)
const sys = parseSizeToBytes(status.sys)
if (sys === 0) return 0
return Math.min(Math.round((alloc / sys) * 100), 100)
}
const alloc = parseSizeToBytes(status.alloc);
const sys = parseSizeToBytes(status.sys);
if (sys === 0) return 0;
return Math.min(Math.round((alloc / sys) * 100), 100);
};
// 闪烁动画类
const getPulseClass = (field: string) => {
return changedFields[field] ? "animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block" : "transition-all duration-300"
}
return changedFields[field]
? 'animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block'
: 'transition-all duration-300';
};
const RenderMetricRow = ({ label, value, field }: { label: string; value: string | number; field?: string }) => (
<div className="flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ""}`}>
{typeof value === "number" ? formatNumber(value) : value}
const RenderMetricRow = ({
label,
value,
field,
}: {
label: string;
value: string | number;
field?: string;
}) => (
<div className='flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150'>
<span className='text-xs font-medium text-muted-foreground'>{label}</span>
<span
className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ''}`}
>
{typeof value === 'number' ? formatNumber(value) : value}
</span>
</div>
)
);
if (loading) {
return (
<div className="space-y-6 p-1">
<div className="flex items-center justify-between border-b border-border/40 pb-4">
<div className="space-y-1">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
<div className='space-y-6 p-1'>
<div className='flex items-center justify-between border-b border-border/40 pb-4'>
<div className='space-y-1'>
<Skeleton className='h-6 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-8" />
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-24' />
<Skeleton className='h-8 w-16' />
<Skeleton className='h-8 w-8' />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
<Skeleton className='h-48 w-full' />
<Skeleton className='h-48 w-full' />
<Skeleton className='h-48 w-full' />
<Skeleton className='h-48 w-full' />
<Skeleton className='h-48 w-full' />
</div>
</div>
)
);
}
return (
<div className="py-6 px-1 space-y-6">
<div className='py-6 px-1 space-y-6'>
{/* 顶部控制栏 */}
<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">
<Activity className="size-5 text-primary" />
<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'>
<Activity className='size-5 text-primary' />
<div>
<h1 className="text-2xl font-semibold tracking-tight">系统状态</h1>
<h1 className='text-2xl font-semibold tracking-tight'>系统状态</h1>
</div>
</div>
<div className="flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm">
<div className="flex items-center gap-2 px-1">
<div className='flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm'>
<div className='flex items-center gap-2 px-1'>
<Switch
id="auto-refresh"
id='auto-refresh'
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
<label
htmlFor='auto-refresh'
className='text-xs font-medium cursor-pointer select-none'
>
自动刷新
</label>
</div>
{autoRefresh && (
<Select value={intervalTime} onValueChange={setIntervalTime}>
<SelectTrigger className="h-7 w-20 text-[11px] bg-background border-border/40">
<SelectTrigger className='h-7 w-20 text-[11px] bg-background border-border/40'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1000">1s</SelectItem>
<SelectItem value="2000">2s</SelectItem>
<SelectItem value="5000">5s</SelectItem>
<SelectItem value="10000">10s</SelectItem>
<SelectItem value='1000'>1s</SelectItem>
<SelectItem value='2000'>2s</SelectItem>
<SelectItem value='5000'>5s</SelectItem>
<SelectItem value='10000'>10s</SelectItem>
</SelectContent>
</Select>
)}
<Button
size="sm"
variant="secondary"
className="h-7 text-[11px] gap-1.5"
size='sm'
variant='secondary'
className='h-7 text-[11px] gap-1.5'
onClick={() => fetchStatus()}
disabled={wavelet}
>
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
@@ -209,130 +245,277 @@ export function SystemStatusManager() {
{status && (
<>
{/* 主网格排版 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
{/* 1. 服务概览 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<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">服务概览</CardTitle>
<CardDescription className="text-[10px]">服务的基础生命指标</CardDescription>
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300'>
<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'>
服务概览
</CardTitle>
<CardDescription className='text-[10px]'>
服务的基础生命指标
</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Activity className="size-4" />
<Badge
variant='secondary'
className='p-1 rounded-full bg-primary/10 text-primary border-none'
>
<Activity className='size-4' />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="服务运行时间" value={status.uptime} field="uptime" />
<RenderMetricRow label="当前 Goroutines 数量" value={status.num_goroutine} field="num_goroutine" />
<RenderMetricRow label="Heap 对象数量" value={status.heap_objects} field="heap_objects" />
<CardContent className='space-y-1'>
<RenderMetricRow
label='服务运行时间'
value={status.uptime}
field='uptime'
/>
<RenderMetricRow
label='当前 Goroutines 数量'
value={status.num_goroutine}
field='num_goroutine'
/>
<RenderMetricRow
label='Heap 对象数量'
value={status.heap_objects}
field='heap_objects'
/>
</CardContent>
</Card>
{/* 2. 内存统计 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<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">内存统计</CardTitle>
<CardDescription className="text-[10px]">主内存消耗概览</CardDescription>
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300'>
<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'>
内存统计
</CardTitle>
<CardDescription className='text-[10px]'>
主内存消耗概览
</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Cpu className="size-4" />
<Badge
variant='secondary'
className='p-1 rounded-full bg-primary/10 text-primary border-none'
>
<Cpu className='size-4' />
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<RenderMetricRow label="当前内存使用量" value={status.alloc} field="alloc" />
<RenderMetricRow label="所有已分配的内存" value={status.total_alloc} field="total_alloc" />
<RenderMetricRow label="内存占用量" value={status.sys} field="sys" />
<RenderMetricRow label="下次 GC 内存回收量" value={status.next_gc} field="next_gc" />
<CardContent className='space-y-3'>
<div className='space-y-1'>
<RenderMetricRow
label='当前内存使用量'
value={status.alloc}
field='alloc'
/>
<RenderMetricRow
label='所有已分配的内存'
value={status.total_alloc}
field='total_alloc'
/>
<RenderMetricRow
label='内存占用量'
value={status.sys}
field='sys'
/>
<RenderMetricRow
label='下次 GC 内存回收量'
value={status.next_gc}
field='next_gc'
/>
</div>
{/* 物理内存水位比例 */}
<div className="space-y-1 px-1 pt-1">
<div className="flex justify-between text-[10px] text-muted-foreground">
<div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>当前使用率 (Alloc / Sys)</span>
<span className="font-mono">{getMemoryUsagePercent()}%</span>
<span className='font-mono'>
{getMemoryUsagePercent()}%
</span>
</div>
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
<Progress value={getMemoryUsagePercent()} className='h-1.5' />
</div>
</CardContent>
</Card>
{/* 3. 堆/栈详情 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<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">堆/栈详情</CardTitle>
<CardDescription className="text-[10px]">运行时堆栈内存空间细节</CardDescription>
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300'>
<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'>
堆/栈详情
</CardTitle>
<CardDescription className='text-[10px]'>
运行时堆栈内存空间细节
</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Database className="size-4" />
<Badge
variant='secondary'
className='p-1 rounded-full bg-primary/10 text-primary border-none'
>
<Database className='size-4' />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="当前 Heap 内存使用量" value={status.heap_alloc} field="heap_alloc" />
<RenderMetricRow label="Heap 内存占用量" value={status.heap_sys} field="heap_sys" />
<RenderMetricRow label="Heap 内存空闲量" value={status.heap_idle} field="heap_idle" />
<RenderMetricRow label="正在使用的 Heap 内存" value={status.heap_inuse} field="heap_inuse" />
<RenderMetricRow label="已释放的 Heap 内存" value={status.heap_released} field="heap_released" />
<RenderMetricRow label="启动 Stack 使用量" value={status.stack_inuse} field="stack_inuse" />
<RenderMetricRow label="已分配的 Stack 内存" value={status.stack_sys} field="stack_sys" />
<CardContent className='space-y-1'>
<RenderMetricRow
label='当前 Heap 内存使用量'
value={status.heap_alloc}
field='heap_alloc'
/>
<RenderMetricRow
label='Heap 内存占用量'
value={status.heap_sys}
field='heap_sys'
/>
<RenderMetricRow
label='Heap 内存空闲量'
value={status.heap_idle}
field='heap_idle'
/>
<RenderMetricRow
label='正在使用的 Heap 内存'
value={status.heap_inuse}
field='heap_inuse'
/>
<RenderMetricRow
label='已释放的 Heap 内存'
value={status.heap_released}
field='heap_released'
/>
<RenderMetricRow
label='启动 Stack 使用量'
value={status.stack_inuse}
field='stack_inuse'
/>
<RenderMetricRow
label='已分配的 Stack 内存'
value={status.stack_sys}
field='stack_sys'
/>
</CardContent>
</Card>
{/* 4. 底层组件与结构体 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<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">底层及结构体内存</CardTitle>
<CardDescription className="text-[10px]">运行时底层管理结构体开销</CardDescription>
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300'>
<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'>
底层及结构体内存
</CardTitle>
<CardDescription className='text-[10px]'>
运行时底层管理结构体开销
</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<HardDrive className="size-4" />
<Badge
variant='secondary'
className='p-1 rounded-full bg-primary/10 text-primary border-none'
>
<HardDrive className='size-4' />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="MSpan 结构内存使用量" value={status.mspan_inuse} field="mspan_inuse" />
<RenderMetricRow label="已分配的 MSpan 结构内存" value={status.mspan_sys} field="mspan_sys" />
<RenderMetricRow label="MCache 结构内存使用量" value={status.mcache_inuse} field="mcache_inuse" />
<RenderMetricRow label="已分配的 MCache 结构内存" value={status.mcache_sys} field="mcache_sys" />
<RenderMetricRow label="已分配的剖析哈希表内存" value={status.buck_hash_sys} field="buck_hash_sys" />
<RenderMetricRow label="已分配的 GC 元数据内存" value={status.gc_sys} field="gc_sys" />
<RenderMetricRow label="其它已分配的系统内存" value={status.other_sys} field="other_sys" />
<CardContent className='space-y-1'>
<RenderMetricRow
label='MSpan 结构内存使用量'
value={status.mspan_inuse}
field='mspan_inuse'
/>
<RenderMetricRow
label='已分配的 MSpan 结构内存'
value={status.mspan_sys}
field='mspan_sys'
/>
<RenderMetricRow
label='MCache 结构内存使用量'
value={status.mcache_inuse}
field='mcache_inuse'
/>
<RenderMetricRow
label='已分配的 MCache 结构内存'
value={status.mcache_sys}
field='mcache_sys'
/>
<RenderMetricRow
label='已分配的剖析哈希表内存'
value={status.buck_hash_sys}
field='buck_hash_sys'
/>
<RenderMetricRow
label='已分配的 GC 元数据内存'
value={status.gc_sys}
field='gc_sys'
/>
<RenderMetricRow
label='其它已分配的系统内存'
value={status.other_sys}
field='other_sys'
/>
</CardContent>
</Card>
{/* 5. 垃圾回收与分配计数 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2">
<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">垃圾回收与分配计数</CardTitle>
<CardDescription className="text-[10px]">GC 历史数据与分配频次</CardDescription>
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2'>
<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'>
垃圾回收与分配计数
</CardTitle>
<CardDescription className='text-[10px]'>
GC 历史数据与分配频次
</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Layers className="size-4" />
<Badge
variant='secondary'
className='p-1 rounded-full bg-primary/10 text-primary border-none'
>
<Layers className='size-4' />
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div>
<RenderMetricRow label="GC 执行次数" value={status.num_gc} field="num_gc" />
<RenderMetricRow label="距离上次 GC 时间" value={status.last_gc_time} field="last_gc_time" />
<RenderMetricRow label="GC 暂停时间总量" value={status.pause_total_ns} field="pause_total_ns" />
<RenderMetricRow label="上次 GC 暂停时间" value={status.last_pause} field="last_pause" />
<RenderMetricRow
label='GC 执行次数'
value={status.num_gc}
field='num_gc'
/>
<RenderMetricRow
label='距离上次 GC 时间'
value={status.last_gc_time}
field='last_gc_time'
/>
<RenderMetricRow
label='GC 暂停时间总量'
value={status.pause_total_ns}
field='pause_total_ns'
/>
<RenderMetricRow
label='上次 GC 暂停时间'
value={status.last_pause}
field='last_pause'
/>
</div>
<div>
<RenderMetricRow label="内存分配次数" value={status.mallocs} field="mallocs" />
<RenderMetricRow label="内存释放次数" value={status.frees} field="frees" />
<RenderMetricRow label="指针查找次数" value={status.lookups} field="lookups" />
<RenderMetricRow
label='内存分配次数'
value={status.mallocs}
field='mallocs'
/>
<RenderMetricRow
label='内存释放次数'
value={status.frees}
field='frees'
/>
<RenderMetricRow
label='指针查找次数'
value={status.lookups}
field='lookups'
/>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
);
}
@@ -1,155 +1,204 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
'use client';
import dynamic from "next/dynamic"
import {useEffect, useMemo} from "react"
import {useQuery} from "@tanstack/react-query"
import {Loader2, Settings} from "lucide-react"
import {useRouter, useSearchParams} from "next/navigation"
import {motion} from "motion/react"
import dynamic from 'next/dynamic';
import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, Settings } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion } from 'motion/react';
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {useAuth} from "@/components/providers/auth-provider"
import services from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuth } from '@/components/providers/auth-provider';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
const tabFallback = (
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
)
<div className='h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20' />
);
const SecurityTab = dynamic(
() => import("@/components/common/settings/security-tab").then((mod) => mod.SecurityTab),
() =>
import('@/components/common/settings/security-tab').then(
(mod) => mod.SecurityTab,
),
{ loading: () => tabFallback },
)
);
const OperationTab = dynamic(
() => import("@/components/common/settings/operation-tab").then((mod) => mod.OperationTab),
() =>
import('@/components/common/settings/operation-tab').then(
(mod) => mod.OperationTab,
),
{ loading: () => tabFallback },
)
);
const SystemTab = dynamic(
() => import("@/components/common/settings/system-tab").then((mod) => mod.SystemTab),
() =>
import('@/components/common/settings/system-tab').then(
(mod) => mod.SystemTab,
),
{ loading: () => tabFallback },
)
);
const OtherTab = dynamic(
() => import("@/components/common/settings/other-tab").then((mod) => mod.OtherTab),
() =>
import('@/components/common/settings/other-tab').then(
(mod) => mod.OtherTab,
),
{ loading: () => tabFallback },
)
);
const InfoTab = dynamic(
() => import("@/components/common/settings/info-tab").then((mod) => mod.InfoTab),
() =>
import('@/components/common/settings/info-tab').then((mod) => mod.InfoTab),
{ loading: () => tabFallback },
)
);
const SystemStatusManager = dynamic(
() => import("./components/system-status").then((mod) => mod.SystemStatusManager),
() =>
import('./components/system-status').then((mod) => mod.SystemStatusManager),
{ loading: () => tabFallback },
)
);
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config
return accumulator
}, {})
accumulator[config.key] = config;
return accumulator;
}, {});
}
export function AdminSettingsPageClient() {
const { user, loading } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const { user, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const activeTab = useMemo(() => {
const rawTab = searchParams.get("tab")
const validTabs = ["security", "operation", "system", "other", "status", "info"]
return rawTab && validTabs.includes(rawTab) ? rawTab : "security"
}, [searchParams])
const rawTab = searchParams.get('tab');
const validTabs = [
'security',
'operation',
'system',
'other',
'status',
'info',
];
return rawTab && validTabs.includes(rawTab) ? rawTab : 'security';
}, [searchParams]);
const handleTabChange = (value: string) => {
router.push(`/admin/settings?tab=${value}`)
}
router.push(`/admin/settings?tab=${value}`);
};
const systemConfigsQuery = useQuery({
queryKey: ["admin", "system-configs"],
queryFn: () => services.adminSystemConfig.listSystemConfigs("system"),
queryKey: ['admin', 'system-configs'],
queryFn: () => services.adminSystemConfig.listSystemConfigs('system'),
enabled: !!user?.is_admin,
})
});
const configs = useMemo(
() => systemConfigMap(systemConfigsQuery.data ?? []),
[systemConfigsQuery.data],
)
);
useEffect(() => {
if (!loading && (!user || !user.is_admin)) {
router.replace("/settings/profile")
router.replace('/settings/profile');
}
}, [user, loading, router])
}, [user, loading, router]);
if (loading || !user || !user.is_admin) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="size-6 animate-spin text-primary" />
<div className='flex items-center justify-center min-h-[400px]'>
<Loader2 className='size-6 animate-spin text-primary' />
</div>
)
);
}
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="py-6 space-y-6 w-full"
transition={{ duration: 0.35, ease: 'easeOut' }}
className='py-6 space-y-6 w-full'
>
<div className="flex items-center gap-2">
<Settings className="size-5 text-primary" />
<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'>系统设置</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className='w-full'
>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='security'
className='px-0 pb-2 text-xs font-semibold'
>
安全设置
</TabsTrigger>
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='operation'
className='px-0 pb-2 text-xs font-semibold'
>
业务设置
</TabsTrigger>
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='system'
className='px-0 pb-2 text-xs font-semibold'
>
系统设置
</TabsTrigger>
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='other'
className='px-0 pb-2 text-xs font-semibold'
>
其他设置
</TabsTrigger>
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='status'
className='px-0 pb-2 text-xs font-semibold'
>
系统状态
</TabsTrigger>
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
系统信息
</TabsTrigger>
</TabsList>
<TabsContent value="security" className="focus-visible:outline-none">
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
<TabsContent value='security' className='focus-visible:outline-none'>
<SecurityTab
configs={configs}
systemConfigsQuery={systemConfigsQuery}
/>
</TabsContent>
<TabsContent value="operation" className="focus-visible:outline-none">
<OperationTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
<TabsContent value='operation' className='focus-visible:outline-none'>
<OperationTab
configs={configs}
systemConfigsQuery={systemConfigsQuery}
/>
</TabsContent>
<TabsContent value="system" className="focus-visible:outline-none">
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
<TabsContent value='system' className='focus-visible:outline-none'>
<SystemTab
configs={configs}
systemConfigsQuery={systemConfigsQuery}
/>
</TabsContent>
<TabsContent value="status" className="focus-visible:outline-none">
<TabsContent value='status' className='focus-visible:outline-none'>
<SystemStatusManager />
</TabsContent>
<TabsContent value="other" className="focus-visible:outline-none">
<TabsContent value='other' className='focus-visible:outline-none'>
<OtherTab configs={configs} />
</TabsContent>
<TabsContent value="info" className="focus-visible:outline-none">
<TabsContent value='info' className='focus-visible:outline-none'>
<InfoTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
);
}
+7 -7
View File
@@ -1,20 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import {Suspense} from "react"
import {Loader2} from "lucide-react"
import {AdminSettingsPageClient} from "./page-client"
import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
import { AdminSettingsPageClient } from './page-client';
export default function AdminSettingsPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="size-6 animate-spin text-primary" />
<div className='flex items-center justify-center min-h-[400px]'>
<Loader2 className='size-6 animate-spin text-primary' />
</div>
}
>
<AdminSettingsPageClient />
</Suspense>
)
}
);
}
+183 -98
View File
@@ -1,16 +1,18 @@
"use client"
'use client';
import * as React from "react"
import {Input} from "@/components/ui/input"
import {Switch} from "@/components/ui/switch"
import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel"
import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {ShieldCheck} from "lucide-react"
import {formatDateTime} from "@/lib/utils"
import type {SystemConfig} from "@/lib/services/admin"
import {AdminProvider, useAdmin} from "@/contexts/admin-context"
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
ManageDetailPanel,
ManagePage,
} from '@/components/common/general/manage-pannel';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ShieldCheck } from 'lucide-react';
import { formatDateTime } from '@/lib/utils';
import type { SystemConfig } from '@/lib/services/admin';
import { AdminProvider, useAdmin } from '@/contexts/admin-context';
/**
* 系统配置
@@ -38,43 +40,51 @@ function SystemConfigDetailPanel({
editData,
onEditDataChange,
onSave,
saving
saving,
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
config: SystemConfig | null;
editData: Partial<SystemConfig>;
onEditDataChange: (
field: keyof SystemConfig,
value: SystemConfig[keyof SystemConfig],
) => void;
onSave: () => void;
saving: boolean;
}) {
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">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
<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'>
配置键
</label>
<p className='text-xs text-muted-foreground font-mono'>
{config?.key}
</p>
</div>
<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">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
<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'>
配置值
</label>
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
checked={
editData.value !== undefined
? editData.value === 'true'
: config?.value === 'true'
}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
onEditDataChange('value', checked ? 'true' : 'false');
}}
/>
) : (
<Input
type={
config?.key && (
config.key.endsWith('_limit') ||
config?.key &&
(config.key.endsWith('_limit') ||
config.key.endsWith('_minutes') ||
config.key.endsWith('_days') ||
config.key.endsWith('_seconds') ||
@@ -85,79 +95,100 @@ function SystemConfigDetailPanel({
config.key.includes('max_') ||
config.key.includes('min_') ||
config.key.includes('limit') ||
config.key.includes('count')
) ? "number" : "text"
config.key.includes('count'))
? 'number'
: 'text'
}
step='1'
min='0'
value={
editData.value !== undefined
? editData.value
: config?.value || ''
}
placeholder={
editData.value === undefined && !config?.value ? '必需' : ''
}
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
const value = e.target.value;
if (value === '') {
onEditDataChange('value', '')
return
onEditDataChange('value', '');
return;
}
onEditDataChange('value', value)
onEditDataChange('value', value);
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
className='!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]'
style={{
MozAppearance: 'textfield'
MozAppearance: 'textfield',
}}
/>
)}
</div>
</div>
<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">配置描述</label>
<div className="flex gap-1 w-[90%]">
<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'>
配置描述
</label>
<div className='flex gap-1 w-[90%]'>
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
type='text'
value={
editData.description !== undefined
? editData.description
: config?.description || ''
}
placeholder='可选描述'
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
const value = e.target.value;
onEditDataChange('description', value);
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
className='!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]'
/>
</div>
</div>
<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">公共可见</label>
<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'>
公共可见
</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
onCheckedChange={(checked) => {
onEditDataChange('visibility', checked ? 1 : 0)
onEditDataChange('visibility', checked ? 1 : 0);
}}
/>
</div>
<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">配置类型</label>
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
config?.type === 'system'
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}>
<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'>
配置类型
</label>
<span
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
config?.type === 'system'
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}
>
{config?.type === 'system' ? '系统配置' : '业务配置'}
</span>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
<div className='px-3 py-2 flex items-center justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
创建时间
</label>
<p className='text-xs text-muted-foreground'>
{config ? formatDateTime(config.created_at) : ''}
</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
);
}
/**
* 系统配置管理组件
*
@@ -173,34 +204,39 @@ export function SystemConfigs() {
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig
} = useAdmin()
updateSystemConfig,
} = useAdmin();
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>(
'business',
);
React.useEffect(() => {
refetchSystemConfigs(activeTab)
}, [activeTab, refetchSystemConfigs])
refetchSystemConfigs(activeTab);
}, [activeTab, refetchSystemConfigs]);
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
visibility: config.visibility,
description: config.description
})
description: config.description,
});
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
const handleSave = async (
config: SystemConfig,
editData: Partial<SystemConfig>,
) => {
if (!config) return;
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
visibility: editData.visibility ?? config.visibility,
description: editData.description ?? config.description
})
}
description: editData.description ?? config.description,
});
};
return (
<ManagePage<SystemConfig>
title="系统配置"
title='系统配置'
icon={ShieldCheck}
data={configs}
loading={loading}
@@ -209,23 +245,72 @@ export function SystemConfigs() {
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
emptyDescription='未发现系统配置'
loadingDescription='配置加载中'
headerExtra={
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'system' | 'business')} className="w-[180px]">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="business" className="text-[11px] h-7">业务配置</TabsTrigger>
<TabsTrigger value="system" className="text-[11px] h-7">系统配置</TabsTrigger>
<Tabs
value={activeTab}
onValueChange={(val) => setActiveTab(val as 'system' | 'business')}
className='w-[180px]'
>
<TabsList className='grid w-full grid-cols-2 h-8'>
<TabsTrigger value='business' className='text-[11px] h-7'>
业务配置
</TabsTrigger>
<TabsTrigger value='system' className='text-[11px] h-7'>
系统配置
</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "公共可见", cell: (item) => <span>{item.visibility === 1 ? "可见" : "不可见"}</span>, width: "80px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
{
header: '配置键',
cell: (item) => (
<span className='font-mono font-medium'>{item.key}</span>
),
width: '200px',
},
{
header: '配置值',
cell: (item) => (
<span
className='truncate max-w-[120px] inline-block'
title={item.value}
>
{item.value}
</span>
),
width: '120px',
},
{
header: '公共可见',
cell: (item) => (
<span>{item.visibility === 1 ? '可见' : '不可见'}</span>
),
width: '80px',
},
{
header: '描述',
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
title={item.description}
>
{item.description}
</span>
),
width: '200px',
},
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
renderDetail={({
selected,
hovered,
editData,
onEditDataChange,
onSave,
saving,
}) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
@@ -235,7 +320,7 @@ export function SystemConfigs() {
/>
)}
/>
)
);
}
export default function SystemConfigPage() {
@@ -243,5 +328,5 @@ export default function SystemConfigPage() {
<AdminProvider>
<SystemConfigs />
</AdminProvider>
)
);
}
@@ -1,201 +1,278 @@
"use client"
'use client';
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
import {
Activity,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react';
import services from "@/lib/services"
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {Spinner} from "@/components/ui/spinner"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import services from '@/lib/services';
import type {
TaskExecution,
TaskExecutionStatus,
TaskMeta,
} from '@/lib/services/admin';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: "等待中",
running: "执行中",
succeeded: "成功",
failed: "失败",
}
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
};
const TRIGGER_LABELS: Record<string, string> = {
system: "系统",
manual: "手动",
retry: "重试",
schedule: "定时",
}
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
};
const PAGE_SIZE = 10
const PAGE_SIZE = 10;
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return format(date, "yyyy-MM-dd HH:mm:ss")
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
function formatDuration(duration: number) {
if (!duration) return "-"
if (duration < 1000) return `${ duration }ms`
return `${ (duration / 1000).toFixed(2) }s`
if (!duration) return '-';
if (duration < 1000) return `${duration}ms`;
return `${(duration / 1000).toFixed(2)}s`;
}
function statusVariant(status: TaskExecutionStatus) {
if (status === "failed") return "destructive"
if (status === "succeeded") return "secondary"
return "outline"
if (status === 'failed') return 'destructive';
if (status === 'succeeded') return 'secondary';
return 'outline';
}
export function TaskExecutionsManager() {
const queryClient = useQueryClient()
const [executionsPage, setExecutionsPage] = useState(1)
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const queryClient = useQueryClient();
const [executionsPage, setExecutionsPage] = useState(1);
const [executionStatus, setExecutionStatus] = useState<
TaskExecutionStatus | 'all'
>('all');
const [executionTaskType, setExecutionTaskType] = useState<string>('all');
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(
null,
);
const [executionPreview, setExecutionPreview] =
useState<TaskExecution | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryKey: ['admin', 'task-types'],
queryFn: () => services.adminTask.getTaskTypes(),
})
});
const executionsQuery = useQuery({
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
queryFn: () => services.adminTask.listTaskExecutions({
page: executionsPage,
page_size: PAGE_SIZE,
status: executionStatus === "all" ? undefined : executionStatus,
task_type: executionTaskType === "all" ? undefined : executionTaskType,
}),
})
queryKey: [
'admin',
'task-executions',
executionsPage,
executionStatus,
executionTaskType,
],
queryFn: () =>
services.adminTask.listTaskExecutions({
page: executionsPage,
page_size: PAGE_SIZE,
status: executionStatus === 'all' ? undefined : executionStatus,
task_type: executionTaskType === 'all' ? undefined : executionTaskType,
}),
});
const detailQuery = useQuery({
queryKey: ["admin", "task-execution", selectedExecutionId],
queryKey: ['admin', 'task-execution', selectedExecutionId],
queryFn: () => services.adminTask.getTaskExecution(selectedExecutionId!),
enabled: detailOpen && !!selectedExecutionId,
})
});
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success("任务已重新下发", {
description: `新任务 ID:${ taskID }`,
})
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
toast.success('任务已重新下发', {
description: `新任务 ID:${taskID}`,
});
void queryClient.invalidateQueries({
queryKey: ['admin', 'task-executions'],
});
},
onError: (err: Error) => {
toast.error("任务重试失败", {
description: err.message || "未知错误",
})
toast.error('任务重试失败', {
description: err.message || '未知错误',
});
},
})
});
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
const executions = executionsQuery.data?.items ?? []
const executionsTotal = executionsQuery.data?.total ?? 0
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
const executionsError = executionsQuery.isError ? executionsQuery.error : null
const selectedExecution = detailQuery.data ?? executionPreview
const detailLoading = detailQuery.isFetching
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? [];
const executions = executionsQuery.data?.items ?? [];
const executionsTotal = executionsQuery.data?.total ?? 0;
const executionsLoading =
executionsQuery.isPending || executionsQuery.isFetching;
const executionsError = executionsQuery.isError
? executionsQuery.error
: null;
const selectedExecution = detailQuery.data ?? executionPreview;
const detailLoading = detailQuery.isFetching;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
[executionsTotal],
)
);
const openExecutionDetail = (execution: TaskExecution) => {
setExecutionPreview(execution)
setSelectedExecutionId(execution.id)
setDetailOpen(true)
}
setExecutionPreview(execution);
setSelectedExecutionId(execution.id);
setDetailOpen(true);
};
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
setExecutionStatus(value)
setExecutionsPage(1)
}
const handleStatusChange = (value: TaskExecutionStatus | 'all') => {
setExecutionStatus(value);
setExecutionsPage(1);
};
const handleTaskTypeChange = (value: string) => {
setExecutionTaskType(value)
setExecutionsPage(1)
}
setExecutionTaskType(value);
setExecutionsPage(1);
};
return (
<div className="space-y-6">
<br/>
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
<SelectTrigger size="sm" className="w-[120px]">
<SelectValue placeholder="状态" />
<div className='space-y-6'>
<br />
<div className='flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex flex-wrap items-center gap-2'>
<Select
value={executionStatus}
onValueChange={(value) =>
handleStatusChange(value as TaskExecutionStatus | 'all')
}
>
<SelectTrigger size='sm' className='w-[120px]'>
<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'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
</SelectContent>
</Select>
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue placeholder="任务类型" />
<Select
value={executionTaskType}
onValueChange={handleTaskTypeChange}
>
<SelectTrigger size='sm' className='w-[180px]'>
<SelectValue placeholder='任务类型' />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部任务</SelectItem>
<SelectItem value='all'>全部任务</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
<SelectItem key={task.type} value={task.type}>
{task.name || task.type}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
<Button
variant='outline'
size='sm'
onClick={() => void executionsQuery.refetch()}
disabled={executionsLoading}
>
{executionsLoading ? (
<Spinner className='size-4' />
) : (
<RefreshCw className='size-4' />
)}
刷新
</Button>
</div>
</div>
{executionsError ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
<div className='p-8 border border-dashed rounded-lg bg-card'>
<ErrorInline
error={executionsError}
onRetry={() => void executionsQuery.refetch()}
className='justify-center'
/>
</div>
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
<LoadingStateWithBorder
icon={Activity}
description='加载任务执行记录中...'
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[900px]">
<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>
<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>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className="cursor-pointer"
className='cursor-pointer'
onClick={() => openExecutionDetail(execution)}
>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{execution.task_name || execution.task_type}
</span>
<span className='font-mono text-[11px] text-muted-foreground'>
{execution.task_id}
</span>
</div>
</TableCell>
<TableCell>
@@ -204,18 +281,21 @@ export function TaskExecutionsManager() {
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
<TableCell className='font-mono text-xs text-muted-foreground'>
{execution.retry_count}/{execution.max_retry}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
<TableCell className='font-mono text-xs text-muted-foreground'>
{formatDuration(execution.duration)}
</TableCell>
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
{execution.error_message || execution.result || "-"}
<TableCell className='max-w-[320px] truncate text-xs text-muted-foreground'>
{execution.error_message || execution.result || '-'}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
<TableCell className='font-mono text-[11px] text-muted-foreground'>
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
@@ -225,150 +305,192 @@ export function TaskExecutionsManager() {
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
<div className='flex items-center justify-between'>
<div className='text-xs text-muted-foreground'>
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
</div>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className="size-4" />
<ChevronLeft className='size-4' />
上一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
variant='outline'
size='sm'
onClick={() =>
setExecutionsPage((page) => Math.min(totalPages, page + 1))
}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
<ChevronRight className="size-4" />
<ChevronRight className='size-4' />
</Button>
</div>
</div>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="w-full p-0 sm:max-w-[640px]">
<SheetHeader className="border-b">
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
<SheetHeader className='border-b'>
<SheetTitle>任务执行详情</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
{selectedExecution?.task_name ||
selectedExecution?.task_type ||
'任务记录'}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className='flex-1 overflow-y-auto px-4 pb-4'>
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
/>
) : 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="mt-2">
<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='mt-2'>
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
{STATUS_LABELS[selectedExecution.status] ||
selectedExecution.status}
</Badge>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">触发来源</div>
<div className="mt-2 text-sm font-medium">
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
触发来源
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selectedExecution.triggered_by] ||
selectedExecution.triggered_by}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">重试次数</div>
<div className="mt-2 font-mono text-sm">
{selectedExecution.retry_count}/{selectedExecution.max_retry}
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
重试次数
</div>
<div className='mt-2 font-mono text-sm'>
{selectedExecution.retry_count}/
{selectedExecution.max_retry}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">耗时</div>
<div className="mt-2 font-mono text-sm">
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selectedExecution.duration)}
</div>
</div>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>任务标识</Label>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selectedExecution.task_id}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='grid gap-2'>
<Label>创建时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.created_at)}
</div>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>开始时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.started_at)}
</div>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>结束时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.finished_at)}
</div>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>更新时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.updated_at)}
</div>
</div>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>执行结果</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 className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selectedExecution.result || '-'}
</div>
</div>
{selectedExecution.error_message && (
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>错误信息</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">
<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>
</div>
)}
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>Payload</Label>
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
{selectedExecution.payload || "{}"}
<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">
<div className='grid gap-2'>
<Label>执行日志</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 || "暂无日志"}
<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 || '暂无日志'}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
<EmptyStateWithBorder
icon={Activity}
description='未选择任务记录'
/>
)}
</div>
<SheetFooter className="border-t">
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
<SheetFooter className='border-t'>
<Button
variant='outline'
onClick={() => void detailQuery.refetch()}
disabled={!selectedExecutionId || detailLoading}
>
{detailLoading ? (
<Spinner className='size-4' />
) : (
<RefreshCw className='size-4' />
)}
刷新详情
</Button>
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
重试任务
</Button>
)}
{selectedExecution &&
selectedExecution.status === 'failed' &&
selectedExecution.retryable && (
<Button
onClick={() => retryMutation.mutate(selectedExecution.id)}
disabled={retryMutation.isPending}
>
{retryMutation.isPending ? (
<Spinner className='size-4' />
) : (
<RotateCcw className='size-4' />
)}
重试任务
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
);
}
@@ -1,116 +1,150 @@
"use client"
'use client';
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Calendar as CalendarIcon,
Clock,
Info,
Layers,
Play,
} from 'lucide-react';
import type {DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {cn} from "@/lib/utils"
import type { DispatchTaskRequest, TaskMeta } from '@/lib/services/admin';
import services from '@/lib/services';
import { buildTaskPayload } from '@/lib/task-param-utils';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {format} from "date-fns"
import {zhCN} from "date-fns/locale"
import {Calendar} from "@/components/ui/calendar"
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
'order_sync': {
icon: Layers,
color: "text-blue-600 dark:text-blue-400",
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
},
'user_gamification': {
icon: Play,
color: "text-amber-600 dark:text-amber-400",
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
},
'dispute_auto_refund': {
icon: Clock,
color: "text-rose-600 dark:text-rose-400",
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
const TASK_CONFIGS: Record<
string,
{
icon: React.ComponentType<{ className?: string }>;
color: string;
gradient: string;
}
}
> = {
order_sync: {
icon: Layers,
color: 'text-blue-600 dark:text-blue-400',
gradient:
'from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500',
},
user_gamification: {
icon: Play,
color: 'text-amber-600 dark:text-amber-400',
gradient:
'from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500',
},
dispute_auto_refund: {
icon: Clock,
color: 'text-rose-600 dark:text-rose-400',
gradient:
'from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500',
},
};
const DEFAULT_TASK_CONFIG = {
icon: Layers,
color: "text-zinc-600 dark:text-zinc-400",
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
}
color: 'text-zinc-600 dark:text-zinc-400',
gradient:
'from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500',
};
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
const timeString = date ? format(date, "HH:mm:ss") : ""
function DatePickerWithTime({
date,
setDate,
}: {
date: Date | undefined;
setDate: (date: Date | undefined) => void;
}) {
const timeString = date ? format(date, 'HH:mm:ss') : '';
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
setDate(undefined)
return
setDate(undefined);
return;
}
const updated = new Date(newDate)
const updated = new Date(newDate);
if (date) {
updated.setHours(date.getHours())
updated.setMinutes(date.getMinutes())
updated.setSeconds(date.getSeconds())
updated.setHours(date.getHours());
updated.setMinutes(date.getMinutes());
updated.setSeconds(date.getSeconds());
} else {
updated.setHours(0, 0, 0, 0)
updated.setHours(0, 0, 0, 0);
}
setDate(updated)
}
setDate(updated);
};
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
if (!newTime) return
const newTime = e.target.value;
if (!newTime) return;
if (date) {
const [hours, minutes, seconds] = newTime.split(':').map(Number)
const updated = new Date(date)
updated.setHours(hours || 0)
updated.setMinutes(minutes || 0)
updated.setSeconds(seconds || 0)
setDate(updated)
const [hours, minutes, seconds] = newTime.split(':').map(Number);
const updated = new Date(date);
updated.setHours(hours || 0);
updated.setMinutes(minutes || 0);
updated.setSeconds(seconds || 0);
setDate(updated);
} else {
const today = new Date()
const [hours, minutes, seconds] = newTime.split(':').map(Number)
today.setHours(hours || 0)
today.setMinutes(minutes || 0)
today.setSeconds(seconds || 0)
setDate(today)
const today = new Date();
const [hours, minutes, seconds] = newTime.split(':').map(Number);
today.setHours(hours || 0);
today.setMinutes(minutes || 0);
today.setSeconds(seconds || 0);
setDate(today);
}
}
};
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<div className='flex items-center gap-2'>
<div className='flex-1'>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
variant={'outline'}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
'w-full justify-start text-left font-normal text-xs h-8',
!date && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
<CalendarIcon className='mr-1 size-3' />
{date ? format(date, 'yyyy-MM-dd') : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode="single"
mode='single'
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
@@ -118,197 +152,218 @@ function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<div className='w-[120px]'>
<Input
type="time"
step="1"
type='time'
step='1'
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
className='text-xs font-mono'
/>
</div>
</div>
)
);
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [dispatching, setDispatching] = useState(false);
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
const [userId, setUserId] = useState("")
const [paramValues, setParamValues] = useState<Record<string, string>>({})
const [startTime, setStartTime] = useState<Date | undefined>(undefined);
const [endTime, setEndTime] = useState<Date | undefined>(undefined);
const [userId, setUserId] = useState('');
const [paramValues, setParamValues] = useState<Record<string, string>>({});
const fetchTaskTypes = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await services.adminTask.getTaskTypes()
setTaskTypes(data)
setLoading(true);
setError(null);
const data = await services.adminTask.getTaskTypes();
setTaskTypes(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
fetchTaskTypes();
}, [fetchTaskTypes]);
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
if (targetTask?.params) {
const initialValues: Record<string, string> = {}
targetTask.params.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
const initialValues: Record<string, string> = {};
targetTask.params.forEach((p) => {
initialValues[p.name] = '';
});
setParamValues(initialValues);
} else {
setParamValues({})
setParamValues({});
}
}
}, [selectedTaskType, taskTypes])
}, [selectedTaskType, taskTypes]);
const handleDispatch = async () => {
if (!selectedTaskType) return
if (!selectedTaskType) return;
try {
setDispatching(true)
setDispatching(true);
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
const params: DispatchTaskRequest = {
task_type: selectedTaskType
}
task_type: selectedTaskType,
};
if (targetTask?.supports_time) {
if (startTime) params.start_time = startTime.toISOString()
if (endTime) params.end_time = endTime.toISOString()
if (startTime) params.start_time = startTime.toISOString();
if (endTime) params.end_time = endTime.toISOString();
}
if (targetTask?.type === 'user_gamification') {
if (userId) params.user_id = userId
if (userId) params.user_id = userId;
}
// Handle dynamic parameters — type coercion (number vs string) is
// centralised in buildTaskPayload; do not inline it here.
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
const { payload, error } = buildTaskPayload(
targetTask.params,
paramValues,
);
if (error) {
toast.error(error)
setDispatching(false)
return
toast.error(error);
setDispatching(false);
return;
}
params.payload = payload ?? undefined
params.payload = payload ?? undefined;
}
const taskID = await services.adminTask.dispatchTask(params)
const taskID = await services.adminTask.dispatchTask(params);
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
})
setDialogOpen(false)
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
});
setDialogOpen(false);
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
setParamValues({})
setStartTime(undefined);
setEndTime(undefined);
setUserId('');
setParamValues({});
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setDispatching(false)
setDispatching(false);
}
}
};
const openDispatchDialog = (type: string) => {
setSelectedTaskType(type)
setDialogOpen(true)
}
setSelectedTaskType(type);
setDialogOpen(true);
};
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return taskTypes.find((t) => t.type === selectedTaskType);
};
return (
<div className="space-y-6">
<br/>
<div className='space-y-6'>
<br />
<div className="space-y-6">
<div className='space-y-6'>
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
<div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline
error={error}
onRetry={fetchTaskTypes}
className='justify-center'
/>
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
<LoadingStateWithBorder
icon={Layers}
description='加载任务类型中...'
/>
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG;
return (
<div
key={`${ task.type }-${ index }`}
key={`${task.type}-${index}`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
'relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500',
config.gradient,
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
<div className='relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500'>
<div className='space-y-2'>
<div className='flex items-start justify-between'>
<div className='space-y-1'>
<h3 className='font-semibold text-base tracking-tight'>
{task.name}
</h3>
<p className='text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]'>
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
<Badge
variant='secondary'
className='font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5'
>
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
<div className='flex flex-wrap gap-1.5'>
<Badge
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
类型:{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">
<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}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<div className='pt-4 mt-1'>
<Button
className="w-full h-7 text-xs"
variant="secondary"
className='w-full h-7 text-xs'
variant='secondary'
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
<Play className='size-3 mr-1' />
立即执行
</Button>
</div>
</div>
</div>
)
);
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
@@ -316,81 +371,111 @@ export function TaskManager() {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
<div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
const meta = getSelectedTaskMeta();
if (!meta) return null;
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG;
const Icon = config.icon;
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
<div className={cn('p-2', config.color)}>
<Icon className='size-4' />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
<div className='flex flex-col'>
<span className='text-xs font-medium'>{meta.name}</span>
<span className='text-[10px] text-muted-foreground font-mono'>
{meta.type}
</span>
</div>
</>
)
);
})()}
</div>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
const targetTask = getSelectedTaskMeta();
if (targetTask?.supports_time) {
return (
<>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
<DatePickerWithTime
date={startTime}
setDate={setStartTime}
/>
<p className='text-xs text-muted-foreground'>
选择同步的起始时间点
</p>
</div>
<div className="grid gap-2">
<div className='grid gap-2'>
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
<p className='text-xs text-muted-foreground'>
选择同步的结束时间点
</p>
</div>
</>
)
);
}
return null
return null;
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return null
const targetTask = getSelectedTaskMeta();
if (
!targetTask ||
!targetTask.params ||
targetTask.params.length === 0
) {
return null;
}
return (
<div className="space-y-4">
<div className='space-y-4'>
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1">
<div key={param.name} className='grid gap-2'>
<Label
htmlFor={`param-${param.name}`}
className='flex items-center gap-1'
>
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
{param.required && (
<span className='text-destructive font-bold'>*</span>
)}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[80px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
className='text-xs min-h-[80px]'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<div className='flex items-center gap-2 pt-1 h-9'>
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
onCheckedChange={(checked) =>
setParamValues((prev) => ({
...prev,
[param.name]: checked ? 'true' : 'false',
}))
}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
</span>
</div>
) : (
@@ -398,47 +483,68 @@ export function TaskManager() {
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
className='text-xs'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
<p className='text-[10px] text-muted-foreground'>
{param.description}
</p>
)}
</div>
))}
</div>
)
);
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
const hasTime = targetTask?.supports_time
const hasParams = targetTask?.params && targetTask.params.length > 0
const targetTask = getSelectedTaskMeta();
const hasTime = targetTask?.supports_time;
const hasParams =
targetTask?.params && targetTask.params.length > 0;
if (!hasTime && !hasParams) {
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" />
<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>
</div>
)
);
}
return null
return null;
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
<Button
variant='ghost'
onClick={() => setDialogOpen(false)}
disabled={dispatching}
className='h-8 text-xs'
>
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
<Button
onClick={handleDispatch}
disabled={dispatching}
className='h-8 text-xs'
>
{dispatching ? (
<Spinner className='size-3' />
) : (
<Play className='size-3' />
)}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}
@@ -1,100 +1,119 @@
"use client"
'use client';
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Clock, Edit2, Info, Plus, RefreshCw, Trash2 } from 'lucide-react';
import type {CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import type {
CreateScheduleRequest,
Schedule,
TaskMeta,
UpdateScheduleRequest,
} from '@/lib/services/admin';
import services from '@/lib/services';
import { buildTaskPayload } from '@/lib/task-param-utils';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
export function TaskSchedulesManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [schedules, setSchedules] = useState<Schedule[]>([])
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
// Modal States
const [dialogOpen, setDialogOpen] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
const [dialogOpen, setDialogOpen] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
// Delete States
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
// Form Fields
const [name, setName] = useState("")
const [selectedTaskType, setSelectedTaskType] = useState("")
const [cron, setCron] = useState("")
const [isActive, setIsActive] = useState(true)
const [paramValues, setParamValues] = useState<Record<string, string>>({})
const [name, setName] = useState('');
const [selectedTaskType, setSelectedTaskType] = useState('');
const [cron, setCron] = useState('');
const [isActive, setIsActive] = useState(true);
const [paramValues, setParamValues] = useState<Record<string, string>>({});
// Fetch Schedules & Task Types
const fetchData = useCallback(async () => {
try {
setLoading(true)
setError(null)
setLoading(true);
setError(null);
const [schedulesData, taskTypesData] = await Promise.all([
services.adminTask.listSchedules(),
services.adminTask.getTaskTypes()
])
setSchedules(schedulesData || [])
setTaskTypes(taskTypesData || [])
services.adminTask.getTaskTypes(),
]);
setSchedules(schedulesData || []);
setTaskTypes(taskTypesData || []);
} catch (err) {
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
// Track task type selection to initialize parameters
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
// Parse existing payload when editing the same task type
try {
const parsed = JSON.parse(editingSchedule.payload || "{}")
const values: Record<string, string> = {}
targetTask?.params?.forEach(p => {
values[p.name] = String(parsed[p.name] ?? "")
})
setParamValues(values)
const parsed = JSON.parse(editingSchedule.payload || '{}');
const values: Record<string, string> = {};
targetTask?.params?.forEach((p) => {
values[p.name] = String(parsed[p.name] ?? '');
});
setParamValues(values);
} catch {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
const initialValues: Record<string, string> = {};
targetTask?.params?.forEach((p) => {
initialValues[p.name] = '';
});
setParamValues(initialValues);
}
} else {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
const initialValues: Record<string, string> = {};
targetTask?.params?.forEach((p) => {
initialValues[p.name] = '';
});
setParamValues(initialValues);
}
} else {
setParamValues({})
setParamValues({});
}
}, [selectedTaskType, taskTypes, editingSchedule])
}, [selectedTaskType, taskTypes, editingSchedule]);
const handleToggleActive = async (schedule: Schedule) => {
try {
@@ -103,61 +122,70 @@ export function TaskSchedulesManager() {
task_type: schedule.task_type,
cron: schedule.cron,
payload: schedule.payload,
is_active: !schedule.is_active
})
is_active: !schedule.is_active,
});
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
setSchedules((prev) =>
prev.map((s) => (s.id === schedule.id ? updated : s)),
);
toast.success(
updated.is_active
? `已启用定时任务:${updated.name}`
: `已停用定时任务:${updated.name}`,
);
} catch (err) {
toast.error("修改任务状态失败", {
description: err instanceof Error ? err.message : "未知错误"
})
toast.error('修改任务状态失败', {
description: err instanceof Error ? err.message : '未知错误',
});
}
}
};
const openCreateDialog = () => {
setEditingSchedule(null)
setName("")
setSelectedTaskType(taskTypes[0]?.type || "")
setCron("0 */2 * * *")
setIsActive(true)
setParamValues({})
setDialogOpen(true)
}
setEditingSchedule(null);
setName('');
setSelectedTaskType(taskTypes[0]?.type || '');
setCron('0 */2 * * *');
setIsActive(true);
setParamValues({});
setDialogOpen(true);
};
const openEditDialog = (schedule: Schedule) => {
setEditingSchedule(schedule)
setName(schedule.name)
setSelectedTaskType(schedule.task_type)
setCron(schedule.cron)
setIsActive(schedule.is_active)
setDialogOpen(true)
}
setEditingSchedule(schedule);
setName(schedule.name);
setSelectedTaskType(schedule.task_type);
setCron(schedule.cron);
setIsActive(schedule.is_active);
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!name.trim()) {
toast.error("任务名称不能为空")
return
toast.error('任务名称不能为空');
return;
}
if (!cron.trim()) {
toast.error("Cron 表达式不能为空")
return
toast.error('Cron 表达式不能为空');
return;
}
try {
setSubmitLoading(true)
setSubmitLoading(true);
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
let payloadStr = '{}'
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
let payloadStr = '{}';
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
const { payload, error } = buildTaskPayload(
targetTask.params,
paramValues,
);
if (error) {
toast.error(error)
setSubmitLoading(false)
return
toast.error(error);
setSubmitLoading(false);
return;
}
payloadStr = payload ?? '{}'
payloadStr = payload ?? '{}';
}
if (editingSchedule) {
@@ -166,145 +194,182 @@ export function TaskSchedulesManager() {
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const updated = await services.adminTask.updateSchedule(editingSchedule.id, req)
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
toast.success("定时任务更新成功")
is_active: isActive,
};
const updated = await services.adminTask.updateSchedule(
editingSchedule.id,
req,
);
setSchedules((prev) =>
prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
);
toast.success('定时任务更新成功');
} else {
const req: CreateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const created = await services.adminTask.createSchedule(req)
setSchedules(prev => [created, ...prev])
toast.success("定时任务创建成功")
is_active: isActive,
};
const created = await services.adminTask.createSchedule(req);
setSchedules((prev) => [created, ...prev]);
toast.success('定时任务创建成功');
}
setDialogOpen(false)
setDialogOpen(false);
} catch (err) {
toast.error("提交失败", {
description: err instanceof Error ? err.message : "未知错误"
})
toast.error('提交失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setSubmitLoading(false)
setSubmitLoading(false);
}
}
};
const openDeleteDialog = (id: string) => {
setDeletingId(id)
setDeleteOpen(true)
}
setDeletingId(id);
setDeleteOpen(true);
};
const handleDelete = async () => {
if (!deletingId) return
if (!deletingId) return;
try {
setDeleteLoading(true)
await services.adminTask.deleteSchedule(deletingId)
setSchedules(prev => prev.filter(s => s.id !== deletingId))
toast.success("定时任务删除成功")
setDeleteOpen(false)
setDeleteLoading(true);
await services.adminTask.deleteSchedule(deletingId);
setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
toast.success('定时任务删除成功');
setDeleteOpen(false);
} catch (err) {
toast.error("删除失败", {
description: err instanceof Error ? err.message : "未知错误"
})
toast.error('删除失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setDeleteLoading(false)
setDeleteLoading(false);
}
}
};
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return taskTypes.find((t) => t.type === selectedTaskType);
};
const getTaskName = (type: string) => {
const meta = taskTypes.find(t => t.type === type)
return meta ? meta.name : type
}
const meta = taskTypes.find((t) => t.type === type);
return meta ? meta.name : type;
};
return (
<div className="space-y-6">
<br/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
<div className='space-y-6'>
<br />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={fetchData}
disabled={loading}
>
{loading ? (
<Spinner className='size-4' />
) : (
<RefreshCw className='size-4' />
)}
刷新
</Button>
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
<Plus className="size-4 mr-1" />
<Button
size='sm'
onClick={openCreateDialog}
disabled={taskTypes.length === 0}
variant={'secondary'}
>
<Plus className='size-4 mr-1' />
新增定时任务
</Button>
</div>
</div>
{error ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
<div className='p-8 border border-dashed rounded-lg bg-card'>
<ErrorInline
error={error}
onRetry={fetchData}
className='justify-center'
/>
</div>
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
<LoadingStateWithBorder
icon={Clock}
description='加载定时任务配置中...'
/>
) : schedules.length === 0 ? (
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
<EmptyStateWithBorder
icon={Clock}
description='暂无定时任务配置,点击上方按钮新增'
/>
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[800px]">
<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="min-w-[200px]">执行参数 (Payload)</TableHead>
<TableHead className="w-[100px] text-center">启用状态</TableHead>
<TableHead className="w-[120px] text-right">操作</TableHead>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务名称</TableHead>
<TableHead className='w-[160px]'>关联异步任务</TableHead>
<TableHead className='w-[120px]'>Cron 表达式</TableHead>
<TableHead className='min-w-[200px]'>
执行参数 (Payload)
</TableHead>
<TableHead className='w-[100px] text-center'>
启用状态
</TableHead>
<TableHead className='w-[120px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">{schedule.name}</TableCell>
<TableCell className='font-medium'>{schedule.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
<div className='flex flex-col gap-0.5'>
<span className='text-xs font-semibold'>
{getTaskName(schedule.task_type)}
</span>
<span className='font-mono text-[10px] text-muted-foreground'>
{schedule.task_type}
</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
<TableCell className='font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold'>
{schedule.cron}
</TableCell>
<TableCell className="max-w-[300px] truncate">
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
{schedule.payload || "{}"}
<TableCell className='max-w-[300px] truncate'>
<code className='text-xs bg-muted/60 px-1 py-0.5 rounded font-mono'>
{schedule.payload || '{}'}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<TableCell className='text-center'>
<div className='flex justify-center'>
<Switch
checked={schedule.is_active}
onCheckedChange={() => handleToggleActive(schedule)}
/>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1.5'>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
variant='ghost'
size='icon'
className='h-7 w-7 text-muted-foreground hover:text-foreground'
onClick={() => openEditDialog(schedule)}
>
<Edit2 className="size-3.5" />
<Edit2 className='size-3.5' />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/90"
variant='ghost'
size='icon'
className='h-7 w-7 text-destructive hover:text-destructive/90'
onClick={() => openDeleteDialog(schedule.id)}
>
<Trash2 className="size-3.5" />
<Trash2 className='size-3.5' />
</Button>
</div>
</TableCell>
@@ -317,36 +382,38 @@ export function TaskSchedulesManager() {
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
<DialogTitle>
{editingSchedule ? '修改定时任务' : '新增定时任务'}
</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="sched-name">任务名称</Label>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='sched-name'>任务名称</Label>
<Input
id="sched-name"
placeholder="例如:每日数据清理"
id='sched-name'
placeholder='例如:每日数据清理'
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
className='text-xs'
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-type">关联异步任务</Label>
<div className='grid gap-2'>
<Label htmlFor='sched-type'>关联异步任务</Label>
<select
id="sched-type"
id='sched-type'
value={selectedTaskType}
onChange={(e) => setSelectedTaskType(e.target.value)}
disabled={!!editingSchedule}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
className='flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
>
{taskTypes.map(t => (
{taskTypes.map((t) => (
<option key={t.type} value={t.type}>
{t.name} ({t.type})
</option>
@@ -354,66 +421,96 @@ export function TaskSchedulesManager() {
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-cron">Cron 表达式</Label>
<div className='grid gap-2'>
<Label htmlFor='sched-cron'>Cron 表达式</Label>
<Input
id="sched-cron"
placeholder="e.g. 0 */2 * * * 或 @daily"
id='sched-cron'
placeholder='e.g. 0 */2 * * * 或 @daily'
value={cron}
onChange={(e) => setCron(e.target.value)}
className="text-xs font-mono"
className='text-xs font-mono'
/>
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
<p className='text-[10px] text-muted-foreground'>
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。
</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>
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
<div className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<Label htmlFor='sched-active'>启用状态</Label>
<p className='text-[10px] text-muted-foreground'>
决定此定时任务是否会定时触发执行。
</p>
</div>
<Switch
id="sched-active"
id='sched-active'
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
const targetTask = getSelectedTaskMeta();
if (
!targetTask ||
!targetTask.params ||
targetTask.params.length === 0
) {
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" />
<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>
</div>
)
);
}
return (
<div className="space-y-4 pt-2 border-t">
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
<div className='space-y-4 pt-2 border-t'>
<Label className='text-xs font-semibold'>
运行参数配置 (Payload)
</Label>
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
<div
key={param.name}
className='grid gap-2 pl-2 border-l-2 border-muted'
>
<Label
htmlFor={`param-${param.name}`}
className='flex items-center gap-1 text-xs'
>
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
{param.required && (
<span className='text-destructive font-bold'>*</span>
)}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[70px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
className='text-xs min-h-[70px]'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<div className='flex items-center gap-2 pt-1 h-9'>
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
onCheckedChange={(checked) =>
setParamValues((prev) => ({
...prev,
[param.name]: checked ? 'true' : 'false',
}))
}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
</span>
</div>
) : (
@@ -421,27 +518,43 @@ export function TaskSchedulesManager() {
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
className='text-xs'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
<p className='text-[10px] text-muted-foreground'>
{param.description}
</p>
)}
</div>
))}
</div>
)
);
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
<Button
variant='ghost'
onClick={() => setDialogOpen(false)}
disabled={submitLoading}
className='h-8 text-xs'
>
取消
</Button>
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
{submitLoading && <Spinner className="size-3 mr-1" />}
<Button
onClick={handleSubmit}
disabled={submitLoading}
className='h-8 text-xs'
>
{submitLoading && <Spinner className='size-3 mr-1' />}
{editingSchedule ? '保存修改' : '确认创建'}
</Button>
</DialogFooter>
@@ -450,24 +563,34 @@ export function TaskSchedulesManager() {
{/* Delete Confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogContent className='sm:max-w-[400px]'>
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
<DialogFooter className='gap-2 sm:gap-0'>
<Button
variant='ghost'
onClick={() => setDeleteOpen(false)}
disabled={deleteLoading}
className='h-8 text-xs'
>
取消
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
{deleteLoading && <Spinner className="size-3 mr-1" />}
<Button
variant='destructive'
onClick={handleDelete}
disabled={deleteLoading}
className='h-8 text-xs'
>
{deleteLoading && <Spinner className='size-3 mr-1' />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}
+48 -33
View File
@@ -1,65 +1,80 @@
"use client"
'use client';
import {Suspense} from "react"
import {useRouter, useSearchParams} from "next/navigation"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {TaskManager} from "./components/task-manager"
import {TaskSchedulesManager} from "./components/task-schedules"
import {TaskExecutionsManager} from "./components/task-executions"
import {Spinner} from "@/components/ui/spinner"
import {Layers} from "lucide-react"
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TaskManager } from './components/task-manager';
import { TaskSchedulesManager } from './components/task-schedules';
import { TaskExecutionsManager } from './components/task-executions';
import { Spinner } from '@/components/ui/spinner';
import { Layers } from 'lucide-react';
function TasksPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const activeTab = searchParams.get("tab") || "tasks"
const searchParams = useSearchParams();
const router = useRouter();
const activeTab = searchParams.get('tab') || 'tasks';
const handleTabChange = (value: string) => {
router.push(`/admin/tasks?tab=${value}`)
}
router.push(`/admin/tasks?tab=${value}`);
};
return (
<div className="py-6 space-y-6">
<div className="flex items-center gap-2">
<Layers className="size-5 text-primary" />
<div className='py-6 space-y-6'>
<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'>任务管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="tasks" className="px-0 pb-2 text-xs font-semibold">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className='w-full'
>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='tasks'
className='px-0 pb-2 text-xs font-semibold'
>
任务管理
</TabsTrigger>
<TabsTrigger value="schedules" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='schedules'
className='px-0 pb-2 text-xs font-semibold'
>
定时任务
</TabsTrigger>
<TabsTrigger value="executions" className="px-0 pb-2 text-xs font-semibold">
<TabsTrigger
value='executions'
className='px-0 pb-2 text-xs font-semibold'
>
任务日志
</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-4 outline-none">
<TabsContent value='tasks' className='space-y-4 outline-none'>
<TaskManager />
</TabsContent>
<TabsContent value="schedules" className="space-y-4 outline-none">
<TabsContent value='schedules' className='space-y-4 outline-none'>
<TaskSchedulesManager />
</TabsContent>
<TabsContent value="executions" className="space-y-4 outline-none">
<TabsContent value='executions' className='space-y-4 outline-none'>
<TaskExecutionsManager />
</TabsContent>
</Tabs>
</div>
)
);
}
export default function TasksPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<Spinner className="h-8 w-8" />
</div>
}>
<Suspense
fallback={
<div className='flex items-center justify-center min-h-[400px]'>
<Spinner className='h-8 w-8' />
</div>
}
>
<TasksPageContent />
</Suspense>
)
);
}
@@ -1,91 +1,97 @@
"use client"
'use client';
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import type {CreateUserRequest} from "@/lib/services/admin"
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAdminUsers } from '@/contexts/admin-users-context';
import type { CreateUserRequest } from '@/lib/services/admin';
const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
email: "",
username: '',
password: '',
nickname: '',
email: '',
is_active: true,
is_admin: false,
}
};
export function CreateUserModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
isOpen: boolean;
onClose: () => void;
}) {
const { createUser } = useAdminUsers()
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const { createUser } = useAdminUsers();
const [form, setForm] = useState<CreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (isOpen) {
setForm(emptyForm)
setErrors({})
setForm(emptyForm);
setErrors({});
} else {
setSaving(false)
setSaving(false);
}
}, [isOpen])
}, [isOpen]);
const validate = () => {
const newErrors: Record<string, string> = {}
const newErrors: Record<string, string> = {};
if (!form.username.trim()) {
newErrors.username = "用户名不能为空"
newErrors.username = '用户名不能为空';
} else if (form.username.trim().length < 3) {
newErrors.username = "用户名长度不能少于 3 位"
newErrors.username = '用户名长度不能少于 3 位';
}
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
newErrors.email = '邮箱格式不正确';
}
if (!form.password) {
newErrors.password = "密码不能为空"
newErrors.password = '密码不能为空';
} else if (form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
newErrors.password = '密码长度不能少于 8 位';
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
e.preventDefault();
if (!validate()) return;
setSaving(true)
setSaving(true);
try {
await createUser({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
email: form.email.trim(),
})
onClose()
});
onClose();
} catch {
// Errors are already handled by context toast notifications
} finally {
setSaving(false)
setSaving(false);
}
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
@@ -93,90 +99,106 @@ export function CreateUserModal({
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'>
<Label htmlFor='username'>用户名</Label>
<Input
id="username"
id='username'
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="请输入用户名 (至少 3 位)"
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
placeholder='请输入用户名 (至少 3 位)'
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
<p className='text-xs text-destructive'>{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Input
id="nickname"
id='nickname'
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Input
id="email"
type="email"
id='email'
type='email'
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
<p className='text-xs text-destructive'>{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<div className='space-y-1.5'>
<Label htmlFor='password'>密码</Label>
<Input
id="password"
type="password"
id='password'
type='password'
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="请输入登录密码 (至少 8 位)"
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='请输入登录密码 (至少 8 位)'
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
<p className='text-xs text-destructive'>{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<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="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
<div className='font-medium text-sm'>启用账户</div>
<div className='text-xs text-muted-foreground'>
禁用后此账号将无法登录系统。
</div>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, is_active: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<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="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='text-xs text-muted-foreground'>
开启后此账号将拥有后台管理权限。
</div>
</div>
<Switch
checked={form.is_admin}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, is_admin: checked }))
}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
<div className='flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "创建中..." : "创建"}
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '创建中...' : '创建'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
);
}
@@ -1,14 +1,20 @@
"use client"
'use client';
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {useAuth} from "@/components/providers/auth-provider"
import type {AdminUser} from "@/lib/services/admin"
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAdminUsers } from '@/contexts/admin-users-context';
import { useAuth } from '@/components/providers/auth-provider';
import type { AdminUser } from '@/lib/services/admin';
import {
AlertDialog,
AlertDialogAction,
@@ -17,15 +23,15 @@ import {
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import {Loader2} from "lucide-react"
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Loader2 } from 'lucide-react';
interface EditUserForm {
nickname: string
email: string
is_admin: boolean
password?: string
nickname: string;
email: string;
is_admin: boolean;
password?: string;
}
export function EditUserModal({
@@ -33,115 +39,116 @@ export function EditUserModal({
onClose,
user,
}: {
isOpen: boolean
onClose: () => void
user: AdminUser | null
isOpen: boolean;
onClose: () => void;
user: AdminUser | null;
}) {
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
const { user: currentUser } = useAuth()
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
const { user: currentUser } = useAuth();
const isSelf =
currentUser && user && currentUser.id.toString() === user.id.toString();
const [form, setForm] = useState<EditUserForm>({
nickname: "",
email: "",
nickname: '',
email: '',
is_admin: false,
password: "",
})
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
password: '',
});
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
let active = true
let active = true;
if (isOpen && user) {
setForm({
nickname: user.nickname || "",
email: user.email || "",
nickname: user.nickname || '',
email: user.email || '',
is_admin: user.is_admin || false,
password: "",
})
setErrors({})
password: '',
});
setErrors({});
getUserDetail(user.id)
.then((detail) => {
if (active && detail) {
setForm({
nickname: detail.nickname || "",
email: detail.email || "",
nickname: detail.nickname || '',
email: detail.email || '',
is_admin: detail.is_admin || false,
password: "",
})
password: '',
});
}
})
.catch(() => {
// ignore fetching error
})
});
} else {
setSaving(false)
setSaving(false);
}
return () => {
active = false
}
}, [isOpen, user, getUserDetail])
active = false;
};
}, [isOpen, user, getUserDetail]);
const validate = () => {
const newErrors: Record<string, string> = {}
const newErrors: Record<string, string> = {};
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
newErrors.email = '邮箱格式不正确';
}
if (form.password && form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
newErrors.password = '密码长度不能少于 8 位';
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!user || !validate()) return
e.preventDefault();
if (!user || !validate()) return;
setSaving(true)
setSaving(true);
try {
await updateUser(user.id, {
nickname: form.nickname.trim() || undefined,
email: form.email.trim(),
is_admin: form.is_admin,
password: form.password?.trim() || undefined,
})
onClose()
});
onClose();
} catch {
// Errors are handled by context toast notifications
} finally {
setSaving(false)
setSaving(false);
}
}
};
const handleDelete = async () => {
if (!user) return
setSaving(true)
if (!user) return;
setSaving(true);
try {
await deleteUser(user)
onClose()
await deleteUser(user);
onClose();
} catch {
// Errors are handled by context toast notifications
} finally {
setSaving(false)
setShowDeleteConfirm(false)
setSaving(false);
setShowDeleteConfirm(false);
}
}
};
if (!user) return null
if (!user) return null;
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑用户</DialogTitle>
<DialogDescription>
@@ -149,89 +156,102 @@ export function EditUserModal({
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'>
<Label
htmlFor='username'
className='text-xs text-muted-foreground'
>
用户名 (不可修改)
</Label>
<Input
id="username"
id='username'
value={user.username}
disabled
className="bg-muted/50 cursor-not-allowed select-none font-mono"
className='bg-muted/50 cursor-not-allowed select-none font-mono'
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Input
id="nickname"
id='nickname'
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Input
id="email"
type="email"
id='email'
type='email'
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
<p className='text-xs text-destructive'>{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">重置密码 (选填)</Label>
<div className='space-y-1.5'>
<Label htmlFor='password'>重置密码 (选填)</Label>
<Input
id="password"
type="password"
id='password'
type='password'
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
<p className='text-xs text-destructive'>{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<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="text-xs text-muted-foreground">
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
<div className='font-medium text-sm'>管理员权限</div>
<div className='text-xs text-muted-foreground'>
{isSelf
? '不能撤销当前登录用户的管理员权限。'
: '开启后此账号将拥有后台管理权限。'}
</div>
</div>
<Switch
checked={form.is_admin}
disabled={!!isSelf || saving}
onCheckedChange={async (checked) => {
setForm((prev) => ({ ...prev, is_admin: checked }))
setSaving(true)
setForm((prev) => ({ ...prev, is_admin: checked }));
setSaving(true);
try {
await updateUser(user.id, {
nickname: form.nickname.trim() || undefined,
email: form.email.trim(),
is_admin: checked,
})
});
} catch {
setForm((prev) => ({ ...prev, is_admin: !checked }))
setForm((prev) => ({ ...prev, is_admin: !checked }));
} finally {
setSaving(false)
setSaving(false);
}
}}
/>
</div>
<div className="flex justify-between items-center pt-2 border-t mt-2">
<div className='flex justify-between items-center pt-2 border-t mt-2'>
<div>
{!user.is_admin && (
<Button
type="button"
variant="destructive"
type='button'
variant='destructive'
onClick={() => setShowDeleteConfirm(true)}
disabled={saving}
>
@@ -239,12 +259,17 @@ export function EditUserModal({
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
<div className='flex gap-2'>
<Button
variant='outline'
type='button'
onClick={onClose}
disabled={saving}
>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "保存中..." : "保存"}
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
@@ -257,24 +282,25 @@ export function EditUserModal({
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
确定要删除用户 {user.nickname || user.username}{' '}
吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
e.preventDefault();
handleDelete();
}}
disabled={saving}
>
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
);
}
@@ -1,21 +1,29 @@
"use client"
'use client';
import * as React from "react"
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
import * as React from 'react';
import {
Globe,
Loader2,
Mail,
MapPin,
ShieldCheck,
Smartphone,
UserCheck,
} from 'lucide-react';
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import type {AdminUser} from "@/lib/services/admin"
import {formatDateTime} from "@/lib/utils"
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import type { AdminUser } from '@/lib/services/admin';
import { formatDateTime } from '@/lib/utils';
interface UserDetailSheetProps {
selectedUser: AdminUser | null
isOpen: boolean
onOpenChange: (open: boolean) => void
detailLoading: boolean
onStatusToggle: (user: AdminUser) => Promise<void>
selectedUser: AdminUser | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
detailLoading: boolean;
onStatusToggle: (user: AdminUser) => Promise<void>;
}
export function UserDetailSheet({
@@ -25,36 +33,43 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const displayValue = (value?: string) => value && value.trim() ? value : "-"
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>
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'>
<SheetTitle className='px-5 py-3'>用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<div className='flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'>
<div className='flex flex-col pb-6'>
<div className='px-5 py-6 border-b border-border/50'>
<div className='flex flex-col items-center text-center gap-3'>
<Avatar className='h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20'>
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
<AvatarFallback className='rounded-full text-xl font-medium bg-secondary text-secondary-foreground'>
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
<div className='space-y-1.5'>
<h3 className='text-lg font-bold tracking-tight'>
{selectedUser.nickname}
</h3>
<div className='flex items-center justify-center gap-2'>
<code className='px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground'>
@{selectedUser.username}
</code>
<Badge
variant='secondary'
className='h-4.5 px-1.5 text-[9px] uppercase font-medium'
>
UID: {selectedUser.id}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
Admin
</Badge>
)}
@@ -62,91 +77,132 @@ export function UserDetailSheet({
</div>
{detailLoading && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
<Loader2 className='size-3 animate-spin' />
正在刷新详情
</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">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
<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'>
注册时间
</span>
<span className='font-mono text-xs font-semibold'>
{
formatDateTime(selectedUser.created_at).split(
' ',
)[0]
}
</span>
</div>
</div>
</div>
</div>
<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">个人资料</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" />
<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'>
个人资料
</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' />
邮箱
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.email)}
</span>
</div>
<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" />
<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' />
手机
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.phone)}
</span>
</div>
<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">
<span className="size-3 flex items-center justify-center font-bold text-[9px]">⚧</span>
<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'>
<span className='size-3 flex items-center justify-center font-bold text-[9px]'>
⚧
</span>
性别
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.gender)}
</span>
</div>
<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" />
<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' />
所在地
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.location)}
</span>
</div>
<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" />
<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' />
网站
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.website)}
</span>
</div>
<div className="flex flex-col gap-2 p-3.5 text-sm">
<span className="text-[10px] text-muted-foreground">简介</span>
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
<div className='flex flex-col gap-2 p-3.5 text-sm'>
<span className='text-[10px] text-muted-foreground'>
简介
</span>
<span className='break-words text-[10px] leading-5'>
{displayValue(selectedUser.bio)}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</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>
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
{selectedUser.is_active ? "正常" : "禁用"}
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
系统记录
</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>
<Badge
variant={
selectedUser.is_active ? 'secondary' : 'outline'
}
className='text-[10px]'
>
{selectedUser.is_active ? '正常' : '禁用'}
</Badge>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">管理员</span>
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>管理员</span>
<span className='font-mono text-[10px]'>
{selectedUser.is_admin ? '是' : '否'}
</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后登录</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="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>注册时间</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="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后更新</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.updated_at)}
</span>
</div>
</div>
</div>
@@ -154,20 +210,20 @@ export function UserDetailSheet({
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<div className='p-4 border-t bg-background/80 backdrop-blur-md shrink-0'>
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
variant={selectedUser.is_active ? 'destructive' : 'default'}
className='w-full h-9 text-xs font-medium transition-all active:scale-[0.98]'
onClick={() => onStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
<ShieldCheck className='size-3 mr-1' />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
<UserCheck className='size-3 mr-1' />
解除封禁
</>
)}
@@ -179,5 +235,5 @@ export function UserDetailSheet({
)}
</SheetContent>
</Sheet>
)
);
}
@@ -1,14 +1,27 @@
"use client"
'use client';
import * as React from "react"
import {ChevronDown, ChevronLeft, ChevronRight, Filter, Loader2, Search, X} from "lucide-react"
import * as React from 'react';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Filter,
Loader2,
Search,
X,
} from 'lucide-react';
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Separator} from "@/components/ui/separator"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {cn} from "@/lib/utils"
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAdminUsers } from '@/contexts/admin-users-context';
import { cn } from '@/lib/utils';
export function UserFilterBar() {
const {
@@ -26,33 +39,35 @@ export function UserFilterBar() {
setSearchUsername,
setSearchEmail,
setStatusFilter,
fetchUsers
} = useAdminUsers()
fetchUsers,
} = useAdminUsers();
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
const totalPages = Math.ceil(total / pageSize);
const hasSearchFilter = Boolean(
searchUserId || searchUsername || searchEmail,
);
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3'>
<div className='flex items-center gap-2 flex-wrap'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
'h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0',
hasSearchFilter && 'bg-primary/5 border-primary/20',
)}
>
<Search className="size-3 mr-1" />
<Search className='size-3 mr-1' />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Separator orientation='vertical' className='mx-1' />
<Badge
variant="default"
className="text-[10px] h-3 px-1 rounded-full"
variant='default'
className='text-[10px] h-3 px-1 rounded-full'
>
!
</Badge>
@@ -60,35 +75,35 @@ export function UserFilterBar() {
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<DropdownMenuContent className='w-56 p-3' align='start'>
<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..."
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入用户 ID...'
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..."
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入 username...'
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="输入邮箱..."
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入邮箱...'
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
variant='ghost'
size='sm'
className='w-full h-6 text-xs'
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setSearchEmail("")
setSearchUserId('');
setSearchUsername('');
setSearchEmail('');
}}
>
清除
@@ -101,21 +116,21 @@ export function UserFilterBar() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
'h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0',
statusFilter !== 'all' && 'bg-primary/5 border-primary/20',
)}
>
<Filter className="size-3" />
<Filter className='size-3' />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Separator orientation='vertical' className='mx-1' />
<Badge
variant="default"
className="text-[10px] h-3 px-1 rounded-full"
variant='default'
className='text-[10px] h-3 px-1 rounded-full'
>
1
</Badge>
@@ -123,107 +138,130 @@ export function UserFilterBar() {
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuContent className='w-[120px]' align='start'>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
onSelect={(e) => {
e.preventDefault();
setStatusFilter('all');
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
<div
className={cn(
'mr-2 flex size-3 items-center justify-center rounded-sm border border-primary',
statusFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'opacity-50',
)}
/>
<span className='text-xs'>全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
onSelect={(e) => {
e.preventDefault();
setStatusFilter('active');
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
<div
className={cn(
'mr-2 flex size-3 items-center justify-center rounded-sm border border-primary',
statusFilter === 'active'
? 'bg-primary text-primary-foreground'
: 'opacity-50',
)}
/>
<span className='text-xs'>正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
onSelect={(e) => {
e.preventDefault();
setStatusFilter('inactive');
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
<div
className={cn(
'mr-2 flex size-3 items-center justify-center rounded-sm border border-primary',
statusFilter === 'inactive'
? 'bg-primary text-primary-foreground'
: 'opacity-50',
)}
/>
<span className='text-xs'>禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Separator orientation='vertical' className='h-6 hidden sm:block' />
<Button
variant="ghost"
size="sm"
variant='ghost'
size='sm'
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setSearchEmail("")
setStatusFilter('all')
setSearchUserId('');
setSearchUsername('');
setSearchEmail('');
setStatusFilter('all');
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
>
<X className="size-3" />
<X className='size-3' />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<Separator className='lg:hidden' />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
<div className='flex items-center gap-1.5 self-end lg:self-auto'>
<span className='text-[10px] text-muted-foreground whitespace-nowrap'>
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<div className='flex items-center border border-dashed rounded-md shadow-none'>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
variant='ghost'
size='icon'
className='h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30'
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
<ChevronLeft className='size-3' />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
<span className='text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed'>
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
variant='ghost'
size='icon'
className='h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30'
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
<ChevronRight className='size-3' />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
<Button
variant='outline'
size='sm'
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
disabled={loading}
>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
<ChevronDown className='size-3 opacity-50' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuContent align='end'>
{[20, 50, 100].map((size) => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
className={cn(
'font-mono text-xs',
pageSize === size && 'bg-accent',
)}
>
{size}条/页
</DropdownMenuItem>
@@ -232,16 +270,16 @@ export function UserFilterBar() {
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
variant='outline'
size='icon'
className='h-6 w-6 border-dashed shadow-none'
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
title='刷新数据'
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
</Button>
</div>
</div>
)
);
}
+192 -135
View File
@@ -1,82 +1,93 @@
"use client"
'use client';
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Eye, Layers, Plus, Pencil, UserRound, UserX,} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Eye, Layers, Pencil, Plus, UserRound, UserX } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type {AdminUser} from "@/lib/services/admin"
import {formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./components/create-user-modal"
import {UserFilterBar} from "./components/user-filter-bar"
import {UserDetailSheet} from "./components/user-detail-sheet"
import {EditUserModal} from "./components/edit-user-modal"
import type { AdminUser } from '@/lib/services/admin';
import { formatDateTime } from '@/lib/utils';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { ErrorInline } from '@/components/layout/error';
import { useAdminUsers } from '@/contexts/admin-users-context';
import { CreateUserModal } from './components/create-user-modal';
import { UserFilterBar } from './components/user-filter-bar';
import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() {
const {
users,
loading,
error,
fetchUsers,
getUserDetail,
updateUserStatus
} = useAdminUsers()
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers();
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
useEffect(() => {
fetchUsers()
}, [fetchUsers])
fetchUsers();
}, [fetchUsers]);
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user)
await updateUserStatus(user);
if (selectedUser?.id === user.id) {
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
setSelectedUser((prev) =>
prev ? { ...prev, is_active: !prev.is_active } : null,
);
}
}
};
const handleShowDetail = async (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
setDetailLoading(true)
setSelectedUser(user);
setDetailOpen(true);
setDetailLoading(true);
try {
const detail = await getUserDetail(user.id)
setSelectedUser(detail)
const detail = await getUserDetail(user.id);
setSelectedUser(detail);
} catch {
setSelectedUser(user)
setSelectedUser(user);
} finally {
setDetailLoading(false)
setDetailLoading(false);
}
}
};
return (
<div className="py-6 space-y-4">
<div className='py-6 space-y-4'>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-2">
<UserRound className="size-5 text-primary" />
<div className='flex items-center justify-between pb-2'>
<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'>用户管理</h1>
</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() => setCreateModalOpen(true)}
>
<Plus className='size-3.5 mr-1' />
新增用户
</Button>
</div>
@@ -85,75 +96,108 @@ export default function UsersPage() {
<UserFilterBar />
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
error={error}
onRetry={() => fetchUsers(true)}
className='justify-center'
/>
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}>
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="whitespace-nowrap min-w-[160px] py-2 h-8 pl-4">邮箱</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
<Table className='w-full caption-bottom text-sm min-w-full'>
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[90px] whitespace-nowrap py-2 h-8'>
ID
</TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户
</TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className='border-dashed hover:bg-muted/30 cursor-pointer group'
onClick={() => handleShowDetail(user)}
>
<TableCell className='font-mono text-[11px] text-muted-foreground py-1'>
{user.id}
</TableCell>
<TableCell className='py-1'>
<div className='flex items-center gap-2'>
<Avatar className='h-7 w-7 rounded-sm border'>
<AvatarImage src={user.avatar_url} />
<AvatarFallback className='rounded-sm text-[10px]'>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='flex flex-col gap-0'>
<div className='flex items-center gap-1.5'>
<span
className='font-medium text-[11px] leading-tight max-w-[100px] truncate'
title={user.nickname}
>
{user.nickname}
</span>
{user.is_admin && (
<Badge
variant='secondary'
className='text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter'
>
ADM
</Badge>
)}
</div>
<div className='flex items-center gap-1.5'>
<span className='text-[10px] text-muted-foreground font-mono leading-tight'>
@{user.username}
</span>
</div>
</div>
</div>
</div>
</TableCell>
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{user.email || "-"}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4'>
{user.email || '-'}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4'>
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1'>
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1'>
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell
className='sticky right-0 text-center bg-background z-10 py-1'
onClick={(e) => e.stopPropagation()}
>
<div className='flex items-center justify-center gap-0.5'>
<Tooltip>
<TooltipTrigger asChild>
<div>
@@ -161,44 +205,56 @@ export default function UsersPage() {
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75"
className='scale-75'
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
<TooltipContent side='top' className='text-xs'>
{user.is_admin
? '管理员账户'
: user.is_active
? '禁用账户'
: '启用账户'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
<Eye className="size-3" />
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => handleShowDetail(user)}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<TooltipContent side='top' className='text-xs'>
查看详情
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
<Pencil className="size-3" />
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => setEditTarget(user)}
>
<Pencil className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<TooltipContent side='top' className='text-xs'>
编辑用户
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TooltipProvider>
</div>
)}
@@ -219,10 +275,11 @@ export default function UsersPage() {
onClose={() => setEditTarget(null)}
/>
{/* 新建用户模态弹窗 */}
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
<CreateUserModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
/>
</div>
)
);
}
+24 -22
View File
@@ -1,33 +1,35 @@
"use client"
'use client';
import * as React from "react"
import {motion} from "motion/react"
import * as React from 'react';
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 { RequireAuth } from '@/components/auth/require-auth';
import { UserFileManager } from '@/components/common/user/file-manager';
import { FolderOpen } from 'lucide-react';
export default function UserFilesPage() {
return (
<RequireAuth>
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
>
{/* 顶部标题区 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-5">
<div className="flex items-center gap-2">
<FolderOpen className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">我的文件</h1>
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: 'easeOut' }}
className='flex w-full flex-col gap-6 py-6'
>
{/* 顶部标题区 */}
<div className='flex flex-col md:flex-row md:items-center justify-between gap-4 pb-5'>
<div className='flex items-center gap-2'>
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
我的文件
</h1>
</div>
</div>
</div>
</div>
<UserFileManager />
</motion.div>
<UserFileManager />
</motion.div>
</RequireAuth>
)
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { HomeMain } from "@/components/common/home/home-main"
import { HomeMain } from '@/components/common/home/home-main';
export default function HomePage() {
return <HomeMain />
return <HomeMain />;
}
+27 -23
View File
@@ -1,47 +1,51 @@
"use client"
import {motion} from "motion/react"
import {usePathname} from "next/navigation"
import {useState} from "react"
import {AppSidebar} from "@/components/layout/sidebar"
import {SiteHeader} from "@/components/layout/header"
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"
import {useAuthRedirect} from "@/hooks/use-auth-redirect"
'use client';
import { motion } from 'motion/react';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { AppSidebar } from '@/components/layout/sidebar';
import { SiteHeader } from '@/components/layout/header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { useAuthRedirect } from '@/hooks/use-auth-redirect';
export default function MainLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
const pathname = usePathname()
const [isFullWidth, setIsFullWidth] = useState(false)
const pathname = usePathname();
const [isFullWidth, setIsFullWidth] = useState(false);
useAuthRedirect()
useAuthRedirect();
return (
<SidebarProvider
className="h-screen"
className='h-screen'
style={
{
"--header-height": "60px",
'--header-height': '60px',
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex flex-col min-w-0 h-screen">
<SiteHeader isFullWidth={isFullWidth} onToggleFullWidth={setIsFullWidth} />
<div className="flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar">
<div className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"}`}>
<SidebarInset className='flex flex-col min-w-0 h-screen'>
<SiteHeader
isFullWidth={isFullWidth}
onToggleFullWidth={setIsFullWidth}
/>
<div className='flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar'>
<div
className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? 'max-w-[1320px]' : 'max-w-full'}`}
>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
ease: "easeOut",
ease: 'easeOut',
}}
className="w-full"
className='w-full'
>
{children}
</motion.div>
@@ -49,5 +53,5 @@ export default function MainLayout({
</div>
</SidebarInset>
</SidebarProvider>
)
}
);
}
@@ -1,7 +1,7 @@
"use client"
'use client';
import {AccessTokenMain} from "@/components/common/settings/access-token"
import { AccessTokenMain } from '@/components/common/settings/access-token';
export default function AccessTokenPage() {
return <AccessTokenMain />
return <AccessTokenMain />;
}
@@ -1,5 +1,5 @@
import { AppearanceMain } from "@/components/common/settings/appearance"
import { AppearanceMain } from '@/components/common/settings/appearance';
export default function AppearancePage() {
return <AppearanceMain />
return <AppearanceMain />;
}
+5 -5
View File
@@ -1,11 +1,11 @@
"use client"
'use client';
import {RequireAuth} from "@/components/auth/require-auth"
import { RequireAuth } from '@/components/auth/require-auth';
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return <RequireAuth>{children}</RequireAuth>
}
return <RequireAuth>{children}</RequireAuth>;
}
@@ -1,5 +1,5 @@
import { NotificationsMain } from "@/components/common/settings/notifications"
import { NotificationsMain } from '@/components/common/settings/notifications';
export default function NotificationsPage() {
return <NotificationsMain />
return <NotificationsMain />;
}
+51 -39
View File
@@ -1,67 +1,79 @@
"use client"
'use client';
import Link from "next/link"
import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card"
import {Bell, Key, Palette, UserRound} from "lucide-react"
import Link from 'next/link';
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from '@/components/ui/card';
import { Bell, Key, Palette, UserRound } from 'lucide-react';
/* 设置项 */
const settingsItems = [
{
title: "个人资料",
description: "查看您的账户信息",
title: '个人资料',
description: '查看您的账户信息',
icon: UserRound,
href: "/settings/profile",
category: "个人设置",
href: '/settings/profile',
category: '个人设置',
},
{
title: "访问令牌 (AccessToken)",
description: "管理用于 API 访问的个人 Token",
title: '访问令牌 (AccessToken)',
description: '管理用于 API 访问的个人 Token',
icon: Key,
href: "/settings/access-token",
category: "个人设置",
href: '/settings/access-token',
category: '个人设置',
},
{
title: "通知设置",
description: "设置您的通知偏好",
title: '通知设置',
description: '设置您的通知偏好',
icon: Bell,
href: "/settings/notifications",
category: "账户设置",
href: '/settings/notifications',
category: '账户设置',
},
{
title: "外观设置",
description: "自定义界面主题 and 显示",
title: '外观设置',
description: '自定义界面主题 and 显示',
icon: Palette,
href: "/settings/appearance",
category: "个人设置",
href: '/settings/appearance',
category: '个人设置',
},
]
];
export default function SettingsPage() {
const groupedSettings = settingsItems.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, typeof settingsItems>)
const groupedSettings = settingsItems.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
},
{} as Record<string, typeof settingsItems>,
);
return (
<div className="space-y-6 py-6">
<div className="font-semibold text-lg">设置</div>
<div className='space-y-6 py-6'>
<div className='font-semibold text-lg'>设置</div>
{Object.entries(groupedSettings).map(([category, items]) => (
<div key={category} className="space-y-4">
<div className="font-medium text-sm text-muted-foreground">{category}</div>
<div className="grid grid-cols-2 gap-4">
<div key={category} className='space-y-4'>
<div className='font-medium text-sm text-muted-foreground'>
{category}
</div>
<div className='grid grid-cols-2 gap-4'>
{items.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="py-2 border border-dashed shadow-none hover:bg-muted/50 transition-colors cursor-pointer h-full">
<Card className='py-2 border border-dashed shadow-none hover:bg-muted/50 transition-colors cursor-pointer h-full'>
<CardContent>
<div className="flex items-center gap-4">
<item.icon className="size-5 text-primary" />
<div className='flex items-center gap-4'>
<item.icon className='size-5 text-primary' />
<div>
<CardTitle className="mb-1 text-sm">{item.title}</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='mb-1 text-sm'>
{item.title}
</CardTitle>
<CardDescription className='text-xs'>
{item.description}
</CardDescription>
</div>
@@ -74,5 +86,5 @@ export default function SettingsPage() {
</div>
))}
</div>
)
);
}
@@ -1,5 +1,5 @@
import { ProfileMain } from "@/components/common/settings/profile"
import { ProfileMain } from '@/components/common/settings/profile';
export default function ProfilePage() {
return <ProfileMain />
return <ProfileMain />;
}
@@ -1,5 +1,5 @@
import {redirect} from "next/navigation"
import { redirect } from 'next/navigation';
export default function SecurityPage() {
redirect("/admin/settings")
redirect('/admin/settings');
}
+60 -20
View File
@@ -1,5 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -8,7 +8,9 @@
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-inter), 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'PingFang SC', 'Microsoft YaHei', monospace;
--font-mono:
var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono',
'PingFang SC', 'Microsoft YaHei', monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -163,19 +165,37 @@
--animate-gold-particle: gold-particle 1s ease-out forwards;
@keyframes redenvelope-shake {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-5deg);
}
75% {
transform: rotate(5deg);
}
}
@keyframes redenvelope-glow {
0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); }
50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); }
0%,
100% {
box-shadow: 0 0 20px rgba(255, 215, 0, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(255, 215, 0, 0.6);
}
}
@keyframes gold-particle {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-100px) scale(0); opacity: 0; }
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(-100px) scale(0);
opacity: 0;
}
}
/*
@@ -185,15 +205,35 @@
--animate-bell-ring: bell-ring 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97);
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
70% { transform: rotate(2deg); }
80% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
0% {
transform: rotate(0deg);
}
10% {
transform: rotate(14deg);
}
20% {
transform: rotate(-12deg);
}
30% {
transform: rotate(10deg);
}
40% {
transform: rotate(-8deg);
}
50% {
transform: rotate(6deg);
}
60% {
transform: rotate(-4deg);
}
70% {
transform: rotate(2deg);
}
80% {
transform: rotate(-1deg);
}
100% {
transform: rotate(0deg);
}
}
}
+20 -22
View File
@@ -1,33 +1,31 @@
import {ImageResponse} from 'next/og'
import {WavesIcon} from 'lucide-react'
import { ImageResponse } from 'next/og';
import { WavesIcon } from 'lucide-react';
export const dynamic = 'force-static'
export const dynamic = 'force-static';
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
};
export const contentType = 'image/png';
export default function Icon() {
return new ImageResponse(
(
<div
style={{
background: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '20%',
}}
>
<WavesIcon color="white" size={20} />
</div>
),
<div
style={{
background: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '20%',
}}
>
<WavesIcon color='white' size={20} />
</div>,
{
...size,
}
)
},
);
}
+22 -24
View File
@@ -1,25 +1,25 @@
import type {Metadata} from "next";
import {Inter} from "next/font/google";
import {Toaster} from "@/components/ui/sonner";
import {ThemeProvider} from "@/components/layout/theme-provider";
import {CustomThemeProvider} from "@/lib/theme";
import {BellRingProvider} from "@/contexts/bell-ring-context";
import {NotificationSettingsProvider} from "@/contexts/notification-settings-context";
import {UserProvider} from "@/contexts/user-context";
import {AppQueryProvider} from "@/components/providers/query-provider";
import {SiteTitleUpdater} from "@/components/providers/title-updater";
import {RobotsMeta} from "@/components/layout/robots-meta";
import "./globals.css";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from '@/components/layout/theme-provider';
import { CustomThemeProvider } from '@/lib/theme';
import { BellRingProvider } from '@/contexts/bell-ring-context';
import { NotificationSettingsProvider } from '@/contexts/notification-settings-context';
import { UserProvider } from '@/contexts/user-context';
import { AppQueryProvider } from '@/components/providers/query-provider';
import { SiteTitleUpdater } from '@/components/providers/title-updater';
import { RobotsMeta } from '@/components/layout/robots-meta';
import './globals.css';
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const metadata: Metadata = {
title: "Wavelet Platform",
description: "通用二次开发标准范式平台",
title: 'Wavelet Platform',
description: '通用二次开发标准范式平台',
};
export default function RootLayout({
@@ -29,16 +29,14 @@ export default function RootLayout({
}>) {
return (
<html
lang="zh-CN"
lang='zh-CN'
className={`hide-scrollbar font-sans ${inter.variable}`}
suppressHydrationWarning
>
<body
className="hide-scrollbar font-sans antialiased"
>
<body className='hide-scrollbar font-sans antialiased'>
<ThemeProvider
attribute="class"
defaultTheme="system"
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
@@ -50,7 +48,7 @@ export default function RootLayout({
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<Toaster position="top-center" />
<Toaster position='top-center' />
</BellRingProvider>
</NotificationSettingsProvider>
</UserProvider>
+30 -28
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "motion/react";
import { Home, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'motion/react';
import { ArrowLeft, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* 404 Not Found Page
@@ -14,23 +14,25 @@ export default function NotFound() {
const router = useRouter();
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20">
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-primary/20 rounded-full blur-[120px] opacity-20 animate-pulse" />
<div className='relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20'>
<div className='absolute inset-0 -z-10 overflow-hidden'>
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-primary/20 rounded-full blur-[120px] opacity-20 animate-pulse' />
</div>
<div className="container px-6 flex flex-col items-center text-center z-10">
<div className='container px-6 flex flex-col items-center text-center z-10'>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="relative mb-6"
transition={{ duration: 0.8, ease: 'easeOut' }}
className='relative mb-6'
>
<h1 className="text-[12rem] md:text-[16rem] font-bold leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-foreground/10 to-foreground/5 select-none">
<h1 className='text-[12rem] md:text-[16rem] font-bold leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-foreground/10 to-foreground/5 select-none'>
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase">Page Not Found</span>
<div className='absolute inset-0 flex items-center justify-center'>
<span className='text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase'>
Page Not Found
</span>
</div>
</motion.div>
@@ -38,32 +40,32 @@ export default function NotFound() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="space-y-8"
className='space-y-8'
>
<p className="text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed">
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
<br />
请检查路径或返回安全地带。
</p>
<div className="flex justify-center gap-4">
<div className='flex justify-center gap-4'>
<Button
variant="secondary"
size="sm"
variant='secondary'
size='sm'
onClick={() => router.back()}
className="rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300"
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className="size-3 opacity-70" />
<ArrowLeft className='size-3 opacity-70' />
上一页
</Button>
<Link href="/">
<Link href='/'>
<Button
variant="default"
size="sm"
className="rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300"
variant='default'
size='sm'
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className="size-3" />
<Home className='size-3' />
首页
</Button>
</Link>
@@ -71,7 +73,7 @@ export default function NotFound() {
</motion.div>
</div>
<div className="absolute bottom-8 text-xs text-muted-foreground/30 font-mono">
<div className='absolute bottom-8 text-xs text-muted-foreground/30 font-mono'>
ERR_HTTP_NOT_FOUND
</div>
</div>
+6 -6
View File
@@ -1,15 +1,15 @@
"use client";
'use client';
import { HeroSection } from "@/components/home/hero-section";
import { DeveloperSection } from "@/components/home/developer-section";
import { FooterSection } from "@/components/home/footer-section";
import { HeroSection } from '@/components/home/hero-section';
import { DeveloperSection } from '@/components/home/developer-section';
import { FooterSection } from '@/components/home/footer-section';
export default function HomePage() {
return (
<div className="relative w-full min-h-screen bg-transparent text-foreground font-sans overflow-x-hidden selection:bg-primary selection:text-primary-foreground z-10">
<div className='relative w-full min-h-screen bg-transparent text-foreground font-sans overflow-x-hidden selection:bg-primary selection:text-primary-foreground z-10'>
<HeroSection />
<DeveloperSection />
<FooterSection />
</div>
)
);
}