mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 07:36:37 +08:00
wavelet init
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {LoginPage} from "@/components/auth/login-page"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {RegisterPage} from "@/components/auth/register-page"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 接口文档"
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={apiSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Wavelet 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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="平台使用指南"
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={howToUseSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
欢迎使用 Wavelet 脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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"
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="隐私政策 (Privacy)"
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={privacySections}
|
||||
description={
|
||||
<span>
|
||||
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
|
||||
<br className="hidden md:block" />
|
||||
以及您享有的相关权利。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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)"
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={termsSections}
|
||||
description={
|
||||
<span>
|
||||
为了保障您的合法权益,请您仔细阅读以下条款。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"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 {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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
interface CacheManagerProps {
|
||||
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 [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)
|
||||
try {
|
||||
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 : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
}
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
await services.adminCache.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
try {
|
||||
await services.adminCache.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化拉取
|
||||
useEffect(() => {
|
||||
fetchCacheStatus()
|
||||
}, [fetchCacheStatus])
|
||||
|
||||
// 当外部刷新触发器改变时自动刷新
|
||||
useEffect(() => {
|
||||
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>
|
||||
</div>
|
||||
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
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" : ""}`} />
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<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>
|
||||
) : cacheStatus ? (
|
||||
<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="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>
|
||||
</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}>
|
||||
{cacheStatus.base_path}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-medium w-full"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
</Button>
|
||||
</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>
|
||||
<Input
|
||||
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"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<Input
|
||||
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 表示永不过期"
|
||||
required
|
||||
/>
|
||||
<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">
|
||||
启用 LRU 淘汰机制
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 清除缓存确认对话框 */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<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>
|
||||
<DialogDescription className="text-xs text-muted-foreground pt-1">
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
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"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"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 {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") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface SQLConsoleProps {
|
||||
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 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
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
// 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<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>
|
||||
</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]"
|
||||
>
|
||||
{/* 顶部编辑工具栏 */}
|
||||
<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-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>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<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="清空编辑器"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 拖动分割线 */}
|
||||
<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="拖动调整大小"
|
||||
>
|
||||
<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 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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
))
|
||||
) : (
|
||||
<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} 毫秒。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface TableBrowserProps {
|
||||
tables: string[]
|
||||
loadingTables: boolean
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) {
|
||||
const [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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 默认选择第一张表
|
||||
useEffect(() => {
|
||||
if (tables.length > 0 && !selectedTable) {
|
||||
setSelectedTable(tables[0])
|
||||
}
|
||||
}, [tables, selectedTable])
|
||||
|
||||
// 当选中的表、页码或外部刷新触发器改变时获取数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
}
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">数据表浏览器</CardTitle>
|
||||
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loadingTables ? (
|
||||
<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>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingData && !tableData ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : tableData && tableData.columns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
{loadingData && (
|
||||
<div className="absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0">
|
||||
<TableRow>
|
||||
{tableData.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2.5">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableData.results && tableData.results.length > 0 ? (
|
||||
tableData.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{tableData.columns.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-2">
|
||||
<span className="truncate max-w-[200px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<div>
|
||||
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<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]"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Layers className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未选择数据表或表结构无法加载</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import services from "@/lib/services"
|
||||
import type {DBOverview} from "@/lib/services/db-manage"
|
||||
|
||||
const sectionFallback = (
|
||||
<div className="h-48 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const TableBrowser = dynamic(
|
||||
() => import("./components/table-browser").then((mod) => mod.TableBrowser),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const CacheManager = dynamic(
|
||||
() => import("./components/cache-manager").then((mod) => mod.CacheManager),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const SQLConsole = dynamic(
|
||||
() => import("./components/sql-console").then((mod) => mod.SQLConsole),
|
||||
{ ssr: false },
|
||||
)
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
}
|
||||
|
||||
export function DatabasePageClient() {
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null)
|
||||
const [tables, setTables] = useState<string[]>([])
|
||||
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
|
||||
const [loadingTables, setLoadingTables] = useState<boolean>(true)
|
||||
const [exporting, setExporting] = useState<boolean>(false)
|
||||
|
||||
// 协调子组件刷新与视图切换状态
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState<number>(0)
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
try {
|
||||
const data = await services.dbManage.getOverview()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
try {
|
||||
const data = await services.dbManage.listTables()
|
||||
setTables(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}
|
||||
|
||||
// 4. 导出数据库备份
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await services.dbManage.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "导出异常",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
|
||||
// 渲染概览卡片骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/40 bg-card/50">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染 SQL 查询控台全屏视图
|
||||
if (showConsole) {
|
||||
return (
|
||||
<SQLConsole
|
||||
dbType={overview?.type}
|
||||
onClose={() => setShowConsole(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 正常页面排版布局
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">数据管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={loadingOverview}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingOverview ? "animate-spin" : ""}`} />
|
||||
刷新数据
|
||||
</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>
|
||||
|
||||
{/* 卡片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>
|
||||
|
||||
{/* 卡片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>
|
||||
|
||||
{/* 卡片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
|
||||
tables={tables}
|
||||
loadingTables={loadingTables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 3. 缓存管理区块 */}
|
||||
<CacheManager
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 4. 底部功能卡片区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
<CardTitle className="text-sm font-semibold">功能区</CardTitle>
|
||||
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 功能一: 数据库导出 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Download className="size-4 text-primary" />
|
||||
数据库备份导出
|
||||
</p>
|
||||
<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"
|
||||
onClick={handleExport}
|
||||
disabled={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" />
|
||||
SQL 查询控台
|
||||
</p>
|
||||
<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"
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {DatabasePageClient} from "./page-client"
|
||||
|
||||
export default function DatabasePage() {
|
||||
return <DatabasePageClient />
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
// 引入模块化拆分后的 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")
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
{/* 1. 标准页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-5 text-primary" />
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
</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">
|
||||
仪表盘与指标卡片
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="focus-visible:outline-none">
|
||||
<DashboardTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="focus-visible:outline-none">
|
||||
<TableTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="controls" className="focus-visible:outline-none">
|
||||
<ControlsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"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"
|
||||
|
||||
export function ControlsTab() {
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: "",
|
||||
category: "database",
|
||||
allowNotify: true,
|
||||
channel: "all",
|
||||
})
|
||||
|
||||
return (
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 输入框 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="task-name" className="text-xs font-medium text-foreground">任务名称</Label>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">任务名称将用于后台执行记录的可读性显示</p>
|
||||
</div>
|
||||
|
||||
{/* 下拉框 */}
|
||||
<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 }))}
|
||||
>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 多选框 Checkbox */}
|
||||
<div className="flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg">
|
||||
<Checkbox
|
||||
id="allow-notify"
|
||||
checked={formData.allowNotify}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allowNotify: checked === true }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<label
|
||||
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>
|
||||
</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">
|
||||
主要操作 (Primary)
|
||||
</Button>
|
||||
<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">
|
||||
辅助线框 (Outline)
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs hover:bg-muted">
|
||||
幽灵按钮 (Ghost)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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" />
|
||||
表单设计规范
|
||||
</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>
|
||||
<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> 去除原生的重叠阴影。
|
||||
</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>,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
|
||||
</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> 类。
|
||||
</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>{`// 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>{`<div className="flex items-start gap-2.5">
|
||||
<Checkbox id="c" className="mt-0.5" />
|
||||
<div className="grid gap-1">
|
||||
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
|
||||
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
|
||||
</div>
|
||||
</div>`}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"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"
|
||||
|
||||
export function DashboardTab() {
|
||||
return (
|
||||
<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" />
|
||||
</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" />
|
||||
容器运行正常,负载平稳
|
||||
</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" />
|
||||
</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">
|
||||
数据已同步远端 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" />
|
||||
</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>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
链接池自动维护中,最大连接限制为 100
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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" />
|
||||
指标卡片 & 仪表盘设计规范
|
||||
</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>
|
||||
</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">
|
||||
<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" />
|
||||
</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">状态说明小字</p>
|
||||
</CardContent>
|
||||
</Card>`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"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"
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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" />
|
||||
成功
|
||||
</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" />
|
||||
运行中
|
||||
</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" />
|
||||
失败
|
||||
</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" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-destructive/10 text-destructive">
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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" />
|
||||
表格设计规范与代码模板
|
||||
</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>
|
||||
</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">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold">编号</TableHead>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
|
||||
<TableCell className="font-mono text-xs">ID-1001</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
|
||||
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
|
||||
成功
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
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"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
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 条更紧凑
|
||||
|
||||
// 搜索防抖
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
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),
|
||||
})
|
||||
|
||||
const storageDriverQuery = useQuery({
|
||||
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 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 handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
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 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)
|
||||
},
|
||||
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("批量下载已开始")
|
||||
},
|
||||
onError: () => toast.error("批量下载失败"),
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<Input
|
||||
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("")}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 打包下载等批量操作 */}
|
||||
<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"
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs px-2.5">
|
||||
已选 {selectedIds.size} 个
|
||||
</Badge>
|
||||
<Button
|
||||
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" />
|
||||
) : (
|
||||
<FileArchive className="size-3.5 mr-1" />
|
||||
)}
|
||||
打包下载
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs h-8 px-2"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<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>
|
||||
) : 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 ? "没有匹配的文件" : "您还没有上传任何文件"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<Checkbox
|
||||
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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => {
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<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/") ? (
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
quality="low"
|
||||
variant="compact"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type, "size-4.5")
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<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}>
|
||||
{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}>
|
||||
{file.file_path}
|
||||
</TableCell>
|
||||
<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}>
|
||||
{file.mime_type}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</TableCell>
|
||||
<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">
|
||||
<Button
|
||||
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" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted"
|
||||
title="下载"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<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="删除"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<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"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-7 px-3"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情查看 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">
|
||||
<SheetTitle>文件详情</SheetTitle>
|
||||
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailTarget && (
|
||||
<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/") ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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()} 字节)
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{" "}
|
||||
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
|
||||
吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"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 {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
"图片": "images",
|
||||
"视频": "videos",
|
||||
"音频": "audio",
|
||||
"文档": "documents",
|
||||
"压缩包": "archives",
|
||||
"其他": "others",
|
||||
}
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: "图片",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
videos: {
|
||||
label: "视频",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
audio: {
|
||||
label: "音频",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
documents: {
|
||||
label: "文档",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
archives: {
|
||||
label: "压缩包",
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
others: {
|
||||
label: "其他",
|
||||
color: "hsl(var(--chart-6))",
|
||||
},
|
||||
count: {
|
||||
label: "新增文件数",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
size: {
|
||||
label: "新增大小",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function FileStats() {
|
||||
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["files", "stats"],
|
||||
queryFn: () => services.adminUpload.getFileStats(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
})
|
||||
|
||||
const stats = statsQuery.data
|
||||
|
||||
const trendData = React.useMemo(() => {
|
||||
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])
|
||||
|
||||
const categoryCountData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.count,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const categorySizeData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.size > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.size,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const maxStats = React.useMemo(() => {
|
||||
if (!stats?.categories || stats.categories.length === 0) {
|
||||
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", 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
|
||||
}
|
||||
if (cat.size > maxSize) {
|
||||
maxSize = cat.size
|
||||
maxSizeName = cat.name
|
||||
}
|
||||
})
|
||||
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize }
|
||||
}, [stats?.categories])
|
||||
|
||||
const trendSummary = React.useMemo(() => {
|
||||
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])
|
||||
|
||||
if (statsQuery.isLoading) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
</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>
|
||||
<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" />
|
||||
</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>
|
||||
<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">
|
||||
<div>
|
||||
<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">
|
||||
<Button
|
||||
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")}
|
||||
>
|
||||
新增大小 (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 }}>
|
||||
<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>
|
||||
<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)" />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
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())}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
|
||||
const formattedValue =
|
||||
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full animate-pulse"
|
||||
style={{
|
||||
backgroundColor:
|
||||
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<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)"}
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 两个饼图分布 */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* 饼图 1: 格式数量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryCountData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryCountData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<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))"
|
||||
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>
|
||||
<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"
|
||||
/>
|
||||
</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> 个文件。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 饼图 2: 格式容量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categorySizeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categorySizeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<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))"
|
||||
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>
|
||||
<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"
|
||||
/>
|
||||
</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>{" "}
|
||||
存储容量。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
"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 {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"
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
const emptyObjectConfig: ObjectStorageConfig = {
|
||||
endpoint: "",
|
||||
region: "",
|
||||
bucket: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
path_style: false,
|
||||
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}
|
||||
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},
|
||||
oss,
|
||||
webdav: {
|
||||
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
|
||||
}
|
||||
|
||||
type StorageMigrationPayload = {
|
||||
target: StorageConfig
|
||||
}
|
||||
|
||||
function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
|
||||
return parsed.target ? normalizeConfig(parsed.target) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : "未知"
|
||||
}
|
||||
|
||||
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
|
||||
const execution = executions[0]
|
||||
if (!execution || execution.status === "succeeded") {
|
||||
return {state: "idle"}
|
||||
}
|
||||
const target = parseMigrationPayload(execution.payload)
|
||||
return {
|
||||
state: execution.status,
|
||||
id: execution.id,
|
||||
task_id: execution.task_id,
|
||||
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 query = useQuery({
|
||||
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)
|
||||
return {
|
||||
config: current,
|
||||
migration,
|
||||
}
|
||||
},
|
||||
refetchInterval: (result) => {
|
||||
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)
|
||||
if (current) {
|
||||
return {
|
||||
...normalized,
|
||||
driver: current.driver,
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
}, [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"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存存储配置失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
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"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "下发存储迁移任务失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已重新下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
|
||||
})
|
||||
|
||||
if (query.isPending || !config) {
|
||||
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 isFormDisabled =
|
||||
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
|
||||
saveMutation.isPending ||
|
||||
migrateMutation.isPending
|
||||
|
||||
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">
|
||||
{isReadOnly && migration && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
存储维护模式
|
||||
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
|
||||
{migration.state}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{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 || "尚未下发"}
|
||||
</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}
|
||||
onClick={() => migration.id && runMutation.mutate(migration.id)}
|
||||
>
|
||||
{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" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<Select
|
||||
value={config.driver}
|
||||
disabled={isFormDisabled}
|
||||
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{config.driver === "local" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField
|
||||
label="根目录"
|
||||
value={config.local.root}
|
||||
placeholder="."
|
||||
onChange={(root) => setConfig({...config, local: {root}})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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">
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
|
||||
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => saveMutation.mutate(config)}
|
||||
className="border-dashed"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||
保存配置
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<Button
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => migrateMutation.mutate(config)}
|
||||
>
|
||||
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
开始迁移
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObjectFields({
|
||||
driver,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
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})} />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
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>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
export default function FilesPage() {
|
||||
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"
|
||||
>
|
||||
{/* 顶部标题区 */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* ──────── TAB 2: 文件列表 ──────── */}
|
||||
<TabsContent value="list" className="outline-hidden">
|
||||
{activeTab === "list" ? <FileList /> : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="outline-hidden">
|
||||
{activeTab === "storage" ? <StorageConfigTab /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import {AdminUsersProvider} from "@/contexts/admin-users-context"
|
||||
import {RequireAdminAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<RequireAdminAuth>
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
</RequireAdminAuth>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {ChevronDown, Edit2, Layers, Loader2, Plus, Trash2,} from "lucide-react"
|
||||
|
||||
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 {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
|
||||
import services from "@/lib/services"
|
||||
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
export function EventsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
const availableChannels = React.useMemo(() => {
|
||||
const customChannels = (channelsQuery.data ?? [])
|
||||
.filter(c => c.enabled)
|
||||
.map(c => c.name)
|
||||
return ["email", ...customChannels]
|
||||
}, [channelsQuery.data])
|
||||
|
||||
// --- 获取通知事件 ---
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-events"],
|
||||
queryFn: () => PushService.listEvents(),
|
||||
})
|
||||
|
||||
const builtInEventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-builtin-events"],
|
||||
queryFn: () => PushService.listBuiltInEvents(),
|
||||
})
|
||||
|
||||
// --- 获取所有系统可调度任务类型 ---
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => services.adminTask.getTaskTypes(),
|
||||
})
|
||||
|
||||
// --- 修改保存事件 Mutation ---
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setEditEventOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.toggleEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("操作失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setCreateEventOpen(false)
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventEnabled(true)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
toast.success("配置删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("配置删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 事件编辑对话框状态 ---
|
||||
const [editEventOpen, setEditEventOpen] = React.useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
|
||||
const [eventChannels, setEventChannels] = React.useState<string[]>([])
|
||||
const [eventTargets, setEventTargets] = React.useState("")
|
||||
const [eventTemplate, setEventTemplate] = React.useState("")
|
||||
|
||||
// 事件创建对话框状态
|
||||
const [createEventOpen, setCreateEventOpen] = React.useState(false)
|
||||
const [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
|
||||
const [newEventKey, setNewEventKey] = React.useState("")
|
||||
const [newEventTaskType, setNewEventTaskType] = React.useState("")
|
||||
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
|
||||
const [newEventTargets, setNewEventTargets] = React.useState("")
|
||||
const [newEventTemplate, setNewEventTemplate] = React.useState("")
|
||||
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
|
||||
|
||||
const availableBuiltInEvents = React.useMemo(() => {
|
||||
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
|
||||
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
|
||||
}, [builtInEventsQuery.data, eventsQuery.data])
|
||||
|
||||
const handleEditEventClick = (event: PushEvent) => {
|
||||
setSelectedEvent(event)
|
||||
setEventChannels(event.channels)
|
||||
setEventTargets((event.targets ?? []).join(", "))
|
||||
setEventTemplate(event.template)
|
||||
setEditEventOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEvent = () => {
|
||||
if (!selectedEvent) return
|
||||
|
||||
try {
|
||||
JSON.parse(eventTemplate)
|
||||
} catch {
|
||||
toast.error("消息模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
|
||||
const targets = eventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: selectedEvent.id,
|
||||
data: {
|
||||
channels: eventChannels,
|
||||
targets,
|
||||
template: eventTemplate,
|
||||
enabled: selectedEvent.enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateEventClick = () => {
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventTargets("")
|
||||
setNewEventTemplate("")
|
||||
setNewEventEnabled(true)
|
||||
setCreateEventOpen(true)
|
||||
}
|
||||
|
||||
const handleNewEventKeyChange = (key: string) => {
|
||||
setNewEventKey(key)
|
||||
const ev = availableBuiltInEvents.find(e => e.key === key)
|
||||
if (ev) {
|
||||
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewEventTaskTypeChange = (taskType: string) => {
|
||||
setNewEventTaskType(taskType)
|
||||
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
|
||||
if (taskMeta) {
|
||||
const defaultTemplate = {
|
||||
title: `任务完成: ${taskMeta.name}`,
|
||||
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
|
||||
level: "INFO",
|
||||
}
|
||||
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (newEventType === "builtin" && !newEventKey) {
|
||||
toast.error("请选择系统事件")
|
||||
return
|
||||
}
|
||||
if (newEventType === "task" && !newEventTaskType) {
|
||||
toast.error("请选择异步任务")
|
||||
return
|
||||
}
|
||||
|
||||
if (newEventTemplate) {
|
||||
try {
|
||||
JSON.parse(newEventTemplate)
|
||||
} catch {
|
||||
toast.error("内容渲染模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const targets = newEventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
createEventMutation.mutate({
|
||||
event_key: newEventType === "builtin" ? newEventKey : undefined,
|
||||
task_type: newEventType === "task" ? newEventTaskType : undefined,
|
||||
channels: newEventChannels,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
template: newEventTemplate || undefined,
|
||||
enabled: newEventEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增通知事件
|
||||
</Button>
|
||||
</div>
|
||||
{eventsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
|
||||
) : eventsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (eventsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无通知配置事件,请点击右上角新增" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<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-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
|
||||
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap 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>
|
||||
{(eventsQuery.data ?? []).map((event) => (
|
||||
<TableRow
|
||||
key={event.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
|
||||
{event.task_type && (
|
||||
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
|
||||
任务
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(event.channels ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
|
||||
) : (
|
||||
event.channels.map(ch => (
|
||||
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
|
||||
{ch === "email" ? "邮件" : ch}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(!event.targets || event.targets.length === 0) ? (
|
||||
<span className="text-muted-foreground text-[10px] font-mono">-</span>
|
||||
) : (
|
||||
event.targets.map(t => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</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">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
配置
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm("确定要删除该通知事件配置吗?")) {
|
||||
deleteEventMutation.mutate(event.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增事件 ==================== */}
|
||||
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增通知事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">事件类型</Label>
|
||||
<div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "builtin"}
|
||||
onChange={() => {
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>系统内置事件</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "task"}
|
||||
onChange={() => {
|
||||
setNewEventType("task")
|
||||
setNewEventKey("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>任务完成事件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newEventType === "builtin" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统事件</Label>
|
||||
{builtInEventsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载系统事件中...</span>
|
||||
</div>
|
||||
) : availableBuiltInEvents.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
所有内置事件都已配置,没有可新增的事件。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择系统事件" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableBuiltInEvents.map(ev => (
|
||||
<SelectItem key={ev.key} value={ev.key} className="text-xs">
|
||||
{ev.name} ({ev.key})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统异步任务</Label>
|
||||
{taskTypesQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载异步任务中...</span>
|
||||
</div>
|
||||
) : (taskTypesQuery.data ?? []).length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
暂无可用的系统任务。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择异步任务" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(taskTypesQuery.data ?? []).map(taskMeta => (
|
||||
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
|
||||
{taskMeta.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "builtin" && newEventKey && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">事件说明:</span>
|
||||
<span>
|
||||
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "task" && newEventTaskType && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">任务说明:</span>
|
||||
<span>
|
||||
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{newEventChannels.length > 0
|
||||
? newEventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={newEventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setNewEventChannels([...newEventChannels, ch])
|
||||
} else {
|
||||
setNewEventChannels(newEventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={newEventTargets}
|
||||
onChange={e => setNewEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{newEventType === "task"
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={newEventTemplate}
|
||||
onChange={e => setNewEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-semibold">启用状态</Label>
|
||||
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newEventEnabled}
|
||||
onCheckedChange={setNewEventEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createEventMutation.isPending || (newEventType === "builtin" && availableBuiltInEvents.length === 0)}
|
||||
onClick={handleCreateEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
启用保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:编辑事件 ==================== */}
|
||||
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>事件通知渠道配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
|
||||
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
|
||||
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.task_type && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
|
||||
<Input
|
||||
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{eventChannels.length > 0
|
||||
? eventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={eventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setEventChannels([...eventChannels, ch])
|
||||
} else {
|
||||
setEventChannels(eventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={eventTargets}
|
||||
onChange={e => setEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{selectedEvent.task_type
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={eventTemplate}
|
||||
onChange={e => setEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
onClick={handleSaveEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
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 {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import type {PushHistory} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
switch (level) {
|
||||
case "CRITICAL":
|
||||
return "destructive"
|
||||
case "IMPORTANT":
|
||||
return "default"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
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 handleRowClick = (hist: PushHistory) => {
|
||||
setSelectedHistory(hist)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryFn: () =>
|
||||
PushService.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
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" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入事件键过滤..."
|
||||
className="pl-8 text-xs h-9"
|
||||
value={historySearch}
|
||||
onChange={e => {
|
||||
setHistorySearch(e.target.value)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Select
|
||||
value={historyStatus}
|
||||
onValueChange={val => {
|
||||
setHistoryStatus(val)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => historiesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historiesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableRow>
|
||||
) : (historiesQuery.data?.results ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
|
||||
无推送历史数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(historiesQuery.data?.results ?? []).map(hist => (
|
||||
<TableRow
|
||||
key={hist.id}
|
||||
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>
|
||||
<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">
|
||||
{hist.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
variant={hist.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold"
|
||||
>
|
||||
{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.error_msg}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(hist.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{historyPage}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage * 10 >= historiesQuery.data.total}
|
||||
onClick={() => setHistoryPage(p => p + 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:推送详情 ==================== */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<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>
|
||||
<DialogDescription>
|
||||
查看该条通知发送的详细审计信息与内容
|
||||
</DialogDescription>
|
||||
</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>
|
||||
<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}>
|
||||
{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">
|
||||
{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>
|
||||
<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>
|
||||
<Badge
|
||||
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold py-0.5 px-2"
|
||||
>
|
||||
{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}
|
||||
</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]">
|
||||
{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">
|
||||
{selectedHistory.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Edit2, Loader2, Play, Plus, Settings, Trash2,} from "lucide-react"
|
||||
|
||||
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 {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
export function SettingsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
// --- 获取动态通道表单字段定义 ---
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels-definitions"],
|
||||
queryFn: () => PushService.listChannelDefinitions(),
|
||||
})
|
||||
|
||||
|
||||
// --- 消息通道 CRUD Mutations ---
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateChannelMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
|
||||
PushService.updateChannel(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
toast.success("通道删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 消息通道与设置相关 State ---
|
||||
const [channelDialogOpen, setChannelDialogOpen] = React.useState(false)
|
||||
const [editingChannel, setEditingChannel] = React.useState<PushChannel | null>(null)
|
||||
const [channelName, setChannelName] = React.useState("")
|
||||
const [channelDescription, setChannelDescription] = React.useState("")
|
||||
const [channelType, setChannelType] = React.useState("custom")
|
||||
const [channelToken, setChannelToken] = React.useState("")
|
||||
const [channelUrl, setChannelUrl] = React.useState("")
|
||||
const [channelOther, setChannelOther] = React.useState("")
|
||||
|
||||
const activeDef = React.useMemo<ChannelDefinition | undefined>(() => {
|
||||
return (definitionsQuery.data ?? []).find(d => d.type === channelType)
|
||||
}, [definitionsQuery.data, channelType])
|
||||
|
||||
const [testChannelOpen, setTestChannelOpen] = React.useState(false)
|
||||
const [testChannelName, setTestChannelName] = React.useState("")
|
||||
const [testChannelTarget, setTestChannelTarget] = React.useState("")
|
||||
const [isTestingChannel, setIsTestingChannel] = React.useState(false)
|
||||
|
||||
const handleChannelTypeChange = (newType: string) => {
|
||||
setChannelType(newType)
|
||||
setChannelUrl("")
|
||||
setChannelToken("")
|
||||
if (newType === "custom") {
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
} else {
|
||||
setChannelOther("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateChannelClick = () => {
|
||||
setEditingChannel(null)
|
||||
setChannelName("")
|
||||
setChannelDescription("")
|
||||
setChannelType("custom")
|
||||
setChannelToken("")
|
||||
setChannelUrl("")
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEditChannelClick = (channel: PushChannel) => {
|
||||
setEditingChannel(channel)
|
||||
setChannelName(channel.name)
|
||||
setChannelDescription(channel.description ?? "")
|
||||
setChannelType(channel.type)
|
||||
setChannelToken(channel.token ?? "")
|
||||
setChannelUrl(channel.url)
|
||||
setChannelOther(channel.other)
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveChannel = () => {
|
||||
if (!channelName && !editingChannel) {
|
||||
toast.error("通道名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
|
||||
toast.error("通道名称只能使用英文字母、数字和下划线")
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeDef) {
|
||||
toast.error("无效的通道类型")
|
||||
return
|
||||
}
|
||||
|
||||
// 动态字段必填性校验
|
||||
for (const field of activeDef.fields) {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
if (field.required && !value.trim()) {
|
||||
toast.error(`${field.label}不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
|
||||
if (channelType !== "email") {
|
||||
if (channelUrl && !channelUrl.startsWith("https://")) {
|
||||
toast.error("地址必须以 https:// 开头以确保安全性")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 结构格式校验
|
||||
if (channelType === "custom") {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("请求体必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
} else if (channelType === "lark" && channelOther) {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("自定义卡片模版必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (editingChannel) {
|
||||
updateChannelMutation.mutate({
|
||||
id: editingChannel.id,
|
||||
data: {
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: editingChannel.enabled,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
createChannelMutation.mutate({
|
||||
name: channelName,
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestChannelClick = (name: string) => {
|
||||
setTestChannelName(name)
|
||||
setTestChannelTarget("")
|
||||
setTestChannelOpen(true)
|
||||
}
|
||||
|
||||
const handleSendChannelTest = async () => {
|
||||
try {
|
||||
setIsTestingChannel(true)
|
||||
toast.info("正在发送测试推送...")
|
||||
await PushService.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
})
|
||||
toast.success("测试推送发送成功,请前往对应平台确认。")
|
||||
setTestChannelOpen(false)
|
||||
} catch (err: unknown) {
|
||||
toast.error("连通性测试失败: " + (err as Error).message)
|
||||
} finally {
|
||||
setIsTestingChannel(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">自定义推送通道</h2>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新建消息通道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channelsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." />
|
||||
) : channelsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (channelsQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-12 border border-dashed rounded-lg flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<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-[120px] whitespace-nowrap py-2 h-8">名称</TableHead>
|
||||
<TableHead className="w-[100px] whitespace-nowrap py-2 h-8">类型</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">备注</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(channelsQuery.data ?? []).map(ch => (
|
||||
<TableRow
|
||||
key={ch.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
>
|
||||
<TableCell className="text-xs font-mono font-bold py-1">
|
||||
{ch.name}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 h-4.5 whitespace-nowrap">
|
||||
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate py-1">
|
||||
{ch.description || <span className="italic">无备注</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={checked => {
|
||||
updateChannelMutation.mutate({
|
||||
id: ch.id,
|
||||
data: {
|
||||
description: ch.description,
|
||||
type: ch.type,
|
||||
token: ch.token,
|
||||
url: ch.url,
|
||||
other: ch.other,
|
||||
enabled: checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</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-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestChannelClick(ch.name)}
|
||||
className="h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Play className="size-2.5 mr-1" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit2 className="size-2.5 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
|
||||
deleteChannelMutation.mutate(ch.id)
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-2.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
|
||||
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
|
||||
value={channelName}
|
||||
onChange={e => setChannelName(e.target.value)}
|
||||
disabled={!!editingChannel}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">备注</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入备注信息"
|
||||
value={channelDescription}
|
||||
onChange={e => setChannelDescription(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">通道类型</Label>
|
||||
<Select value={channelType} onValueChange={handleChannelTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definitionsQuery.data ?? []).map(d => (
|
||||
<SelectItem key={d.type} value={d.type} className="text-xs">
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeDef && (
|
||||
<>
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
|
||||
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{activeDef.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeDef.fields.map(field => {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
const onChange = (val: string) => {
|
||||
if (field.key === "url") setChannelUrl(val);
|
||||
else if (field.key === "token") setChannelToken(val);
|
||||
else setChannelOther(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom post helper templates card */}
|
||||
{channelType === "custom" && (
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
|
||||
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: "$title\n$description\n$content\n$url"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载飞书 Webhook 模版");
|
||||
}}
|
||||
>
|
||||
飞书 Webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
title: "$title",
|
||||
text: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载钉钉机器人模版");
|
||||
}}
|
||||
>
|
||||
钉钉群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
content: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载企业微信机器人模版");
|
||||
}}
|
||||
>
|
||||
企业微信群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://api.day.app/push");
|
||||
setChannelOther(JSON.stringify({
|
||||
device_key: "$to",
|
||||
title: "$title",
|
||||
body: "$content",
|
||||
url: "$url"
|
||||
}, null, 2));
|
||||
toast.success("已加载 Bark App 模版");
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
|
||||
onClick={handleSaveChannel}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:测试渠道连通性 ==================== */}
|
||||
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送测试通知</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">推送通道名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={testChannelName}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
|
||||
value={testChannelTarget}
|
||||
onChange={e => setTestChannelTarget(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isTestingChannel}
|
||||
onClick={handleSendChannelTest}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
发送测试
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
export default function PushAdminPage() {
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div>
|
||||
<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" />
|
||||
事件管理
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
{/* ==================== 1. 事件管理 TAB ==================== */}
|
||||
<TabsContent value="events" className="focus-visible:outline-none">
|
||||
<EventsTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 2. 通知历史 TAB ==================== */}
|
||||
<TabsContent value="histories" className="focus-visible:outline-none">
|
||||
<HistoriesTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
|
||||
<TabsContent value="settings" className="focus-visible:outline-none">
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"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"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
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 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)
|
||||
try {
|
||||
const data = await services.adminStatus.getSystemStatus()
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
const changes: Record<string, boolean> = {}
|
||||
Object.keys(data).forEach((key) => {
|
||||
const k = key as keyof SystemStatus
|
||||
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
|
||||
changes[k] = true
|
||||
}
|
||||
})
|
||||
setChangedFields(changes)
|
||||
// 1秒后清除变化状态
|
||||
setTimeout(() => {
|
||||
setChangedFields({})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
prevStatusRef.current = data
|
||||
} catch (err) {
|
||||
toast.error("获取系统状态失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setWavelet(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
// 首次加载
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const time = parseInt(intervalTime, 10)
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchStatus(true)
|
||||
}, time)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, intervalTime, fetchStatus])
|
||||
|
||||
// 计算内存分配百分比 (Alloc / Sys)
|
||||
const getMemoryUsagePercent = () => {
|
||||
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()
|
||||
|
||||
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 getPulseClass = (field: string) => {
|
||||
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}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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"
|
||||
onClick={() => fetchStatus()}
|
||||
disabled={wavelet}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<>
|
||||
{/* 主网格排版 */}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
{/* 物理内存水位比例 */}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"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} 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"
|
||||
|
||||
const tabFallback = (
|
||||
<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),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OperationTab = dynamic(
|
||||
() => 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),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OtherTab = dynamic(
|
||||
() => 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),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const SystemStatusManager = dynamic(
|
||||
() => 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
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function AdminSettingsPageClient() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
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")
|
||||
}
|
||||
}, [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-indigo-500" />
|
||||
</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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="security" 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>
|
||||
<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>
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
<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>
|
||||
<TabsContent value="system" className="focus-visible:outline-none">
|
||||
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="focus-visible:outline-none">
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="focus-visible:outline-none">
|
||||
<OtherTab configs={configs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info" className="focus-visible:outline-none">
|
||||
<InfoTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {AdminSettingsPageClient} from "./page-client"
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <AdminSettingsPageClient />
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"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"
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置
|
||||
* 显示系统配置的详细信息和编辑面板
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigDetailPanel
|
||||
* config={config}
|
||||
* editData={editData}
|
||||
* onEditDataChange={onEditDataChange}
|
||||
* onSave={onSave}
|
||||
* saving={saving}
|
||||
* />
|
||||
* ```
|
||||
* @param {SystemConfig} config - 系统配置
|
||||
* @param {Partial<SystemConfig>} editData - 编辑数据
|
||||
* @param {function} onEditDataChange - 编辑数据改变回调
|
||||
* @param {function} onSave - 保存回调
|
||||
* @param {boolean} saving - 是否正在保存
|
||||
* @returns {React.ReactNode} 系统配置详情面板组件
|
||||
*/
|
||||
function SystemConfigDetailPanel({
|
||||
config,
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving
|
||||
}: {
|
||||
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>
|
||||
</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">
|
||||
{config?.key.endsWith('_enabled') ? (
|
||||
<Switch
|
||||
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
|
||||
onCheckedChange={(checked) => {
|
||||
onEditDataChange('value', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={
|
||||
config?.key && (
|
||||
config.key.endsWith('_limit') ||
|
||||
config.key.endsWith('_minutes') ||
|
||||
config.key.endsWith('_days') ||
|
||||
config.key.endsWith('_seconds') ||
|
||||
config.key.endsWith('_hours') ||
|
||||
config.key.endsWith('_ttl') ||
|
||||
config.key.endsWith('_max') ||
|
||||
config.key.endsWith('_min') ||
|
||||
config.key.includes('max_') ||
|
||||
config.key.includes('min_') ||
|
||||
config.key.includes('limit') ||
|
||||
config.key.includes('count')
|
||||
) ? "number" : "text"
|
||||
}
|
||||
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
|
||||
if (value === '') {
|
||||
onEditDataChange('value', '')
|
||||
return
|
||||
}
|
||||
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]"
|
||||
style={{
|
||||
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%]">
|
||||
<Input
|
||||
type="text"
|
||||
value={editData.description !== undefined ? editData.description : (config?.description || '')}
|
||||
placeholder="可选描述"
|
||||
onChange={(e) => {
|
||||
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]"
|
||||
/>
|
||||
</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>
|
||||
<Switch
|
||||
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
|
||||
onCheckedChange={(checked) => {
|
||||
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'
|
||||
}`}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</ManageDetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置管理组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigs />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 系统配置管理组件
|
||||
*/
|
||||
export function SystemConfigs() {
|
||||
const {
|
||||
systemConfigs: configs,
|
||||
systemConfigsLoading: loading,
|
||||
systemConfigsError: error,
|
||||
refetchSystemConfigs,
|
||||
updateSystemConfig
|
||||
} = useAdmin()
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
|
||||
|
||||
React.useEffect(() => {
|
||||
refetchSystemConfigs(activeTab)
|
||||
}, [activeTab, refetchSystemConfigs])
|
||||
|
||||
const getInitialEditData = (config: SystemConfig) => ({
|
||||
value: config.value,
|
||||
visibility: config.visibility,
|
||||
description: config.description
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ManagePage<SystemConfig>
|
||||
title="系统配置"
|
||||
icon={ShieldCheck}
|
||||
data={configs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onReload={() => refetchSystemConfigs(activeTab)}
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
getId={(config) => config.key}
|
||||
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>
|
||||
</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" },
|
||||
]}
|
||||
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
|
||||
<SystemConfigDetailPanel
|
||||
config={selected || hovered}
|
||||
editData={editData}
|
||||
onEditDataChange={onEditDataChange}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SystemConfigPage() {
|
||||
return (
|
||||
<AdminProvider>
|
||||
<SystemConfigs />
|
||||
</AdminProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
"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 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: "失败",
|
||||
}
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: "系统",
|
||||
manual: "手动",
|
||||
retry: "重试",
|
||||
schedule: "定时",
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
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"
|
||||
}
|
||||
|
||||
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 taskTypesQuery = useQuery({
|
||||
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,
|
||||
}),
|
||||
})
|
||||
|
||||
const detailQuery = useQuery({
|
||||
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"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
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 totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
|
||||
[executionsTotal],
|
||||
)
|
||||
|
||||
const openExecutionDetail = (execution: TaskExecution) => {
|
||||
setExecutionPreview(execution)
|
||||
setSelectedExecutionId(execution.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
|
||||
setExecutionStatus(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
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="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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="任务类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部任务</SelectItem>
|
||||
{taskTypes.map((task) => (
|
||||
<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>
|
||||
</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>
|
||||
) : executionsLoading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
|
||||
) : (
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((execution) => (
|
||||
<TableRow
|
||||
key={execution.id}
|
||||
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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(execution.status)}>
|
||||
{STATUS_LABELS[execution.status] || execution.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
|
||||
</TableCell>
|
||||
<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">
|
||||
{formatDuration(execution.duration)}
|
||||
</TableCell>
|
||||
<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">
|
||||
{formatDateTime(execution.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={executionsPage <= 1 || executionsLoading}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
|
||||
disabled={executionsPage >= totalPages || executionsLoading}
|
||||
>
|
||||
下一页
|
||||
<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">
|
||||
<SheetTitle>任务执行详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{detailLoading && !selectedExecution ? (
|
||||
<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">
|
||||
<Badge variant={statusVariant(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>
|
||||
</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>
|
||||
</div>
|
||||
<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">
|
||||
<Label>任务标识</Label>
|
||||
<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">
|
||||
<Label>创建时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>更新时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{selectedExecution.error_message && (
|
||||
<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">
|
||||
{selectedExecution.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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" />}
|
||||
刷新详情
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"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 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"
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const updated = new Date(newDate)
|
||||
|
||||
if (date) {
|
||||
updated.setHours(date.getHours())
|
||||
updated.setMinutes(date.getMinutes())
|
||||
updated.setSeconds(date.getSeconds())
|
||||
} else {
|
||||
updated.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
setDate(updated)
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"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>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeString}
|
||||
onChange={handleTimeChange}
|
||||
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 [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 fetchTaskTypes = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await services.adminTask.getTaskTypes()
|
||||
setTaskTypes(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
|
||||
useEffect(() => {
|
||||
if (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)
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}
|
||||
}, [selectedTaskType, taskTypes])
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!selectedTaskType) return
|
||||
|
||||
try {
|
||||
setDispatching(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: selectedTaskType
|
||||
}
|
||||
|
||||
if (targetTask?.supports_time) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setDispatching(false)
|
||||
return
|
||||
}
|
||||
params.payload = payload ?? undefined
|
||||
}
|
||||
|
||||
const taskID = await services.adminTask.dispatchTask(params)
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
|
||||
})
|
||||
setDialogOpen(false)
|
||||
|
||||
setStartTime(undefined)
|
||||
setEndTime(undefined)
|
||||
setUserId("")
|
||||
setParamValues({})
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDispatchDialog = (type: string) => {
|
||||
setSelectedTaskType(type)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
|
||||
<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>
|
||||
) : loading && taskTypes.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
|
||||
) : (
|
||||
<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
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ task.type }-${ index }`}
|
||||
className={cn(
|
||||
"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]">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
类型:{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">
|
||||
重试:{task.max_retry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1">
|
||||
<Button
|
||||
className="w-full h-7 text-xs"
|
||||
variant="secondary"
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置任务参数并启动,任务将在后台异步执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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">
|
||||
{(() => {
|
||||
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>
|
||||
<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()
|
||||
if (targetTask?.supports_time) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<DatePickerWithTime date={startTime} setDate={setStartTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<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">
|
||||
{param.label}
|
||||
{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 }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<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' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
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 }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<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
|
||||
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" />
|
||||
<span>此任务无需额外配置参数,确认后将直接下发。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<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" />}
|
||||
{dispatching ? '下发中' : '启动'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"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 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[]>([])
|
||||
|
||||
// Modal States
|
||||
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)
|
||||
|
||||
// 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>>({})
|
||||
|
||||
// Fetch Schedules & Task Types
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [schedulesData, taskTypesData] = await Promise.all([
|
||||
services.adminTask.listSchedules(),
|
||||
services.adminTask.getTaskTypes()
|
||||
])
|
||||
setSchedules(schedulesData || [])
|
||||
setTaskTypes(taskTypesData || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Track task type selection to initialize parameters
|
||||
useEffect(() => {
|
||||
if (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)
|
||||
} catch {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}, [selectedTaskType, taskTypes, editingSchedule])
|
||||
|
||||
const handleToggleActive = async (schedule: Schedule) => {
|
||||
try {
|
||||
const updated = await services.adminTask.updateSchedule(schedule.id, {
|
||||
name: schedule.name,
|
||||
task_type: schedule.task_type,
|
||||
cron: schedule.cron,
|
||||
payload: schedule.payload,
|
||||
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}`)
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
|
||||
const openEditDialog = (schedule: Schedule) => {
|
||||
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
|
||||
}
|
||||
if (!cron.trim()) {
|
||||
toast.error("Cron 表达式不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
let payloadStr = '{}'
|
||||
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setSubmitLoading(false)
|
||||
return
|
||||
}
|
||||
payloadStr = payload ?? '{}'
|
||||
}
|
||||
|
||||
if (editingSchedule) {
|
||||
const req: UpdateScheduleRequest = {
|
||||
name,
|
||||
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("定时任务更新成功")
|
||||
} 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("定时任务创建成功")
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
} catch (err) {
|
||||
toast.error("提交失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setDeletingId(id)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingId) return
|
||||
try {
|
||||
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 : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
const getTaskName = (type: string) => {
|
||||
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" />}
|
||||
刷新
|
||||
</Button>
|
||||
<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>
|
||||
) : loading && schedules.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
|
||||
) : (
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedules.map((schedule) => (
|
||||
<TableRow key={schedule.id}>
|
||||
<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>
|
||||
</TableCell>
|
||||
<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 || "{}"}
|
||||
</code>
|
||||
</TableCell>
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
>
|
||||
<Edit2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/90"
|
||||
onClick={() => openDeleteDialog(schedule.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置定时任务的调度参数和运行载荷。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-name">任务名称</Label>
|
||||
<Input
|
||||
id="sched-name"
|
||||
placeholder="例如:每日数据清理"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-type">关联异步任务</Label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{taskTypes.map(t => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.name} ({t.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-cron">Cron 表达式</Label>
|
||||
<Input
|
||||
id="sched-cron"
|
||||
placeholder="e.g. 0 */2 * * * 或 @daily"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
<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>
|
||||
<Switch
|
||||
id="sched-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
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" />
|
||||
<span>所选任务类型不需要运行参数参数。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<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">
|
||||
{param.label}
|
||||
{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 }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<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' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
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 }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<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>
|
||||
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
|
||||
{submitLoading && <Spinner className="size-3 mr-1" />}
|
||||
{editingSchedule ? '保存修改' : '确认创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<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">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
|
||||
{deleteLoading && <Spinner className="size-3 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"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"
|
||||
|
||||
function TasksPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const activeTab = searchParams.get("tab") || "tasks"
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
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>
|
||||
<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">
|
||||
任务管理
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks" className="space-y-4 outline-none">
|
||||
<TaskManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="schedules" className="space-y-4 outline-none">
|
||||
<TaskSchedulesManager />
|
||||
</TabsContent>
|
||||
<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>
|
||||
}>
|
||||
<TasksPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"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"
|
||||
|
||||
const emptyForm: CreateUserRequest = {
|
||||
username: "",
|
||||
password: "",
|
||||
nickname: "",
|
||||
email: "",
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
}
|
||||
|
||||
export function CreateUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
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>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm(emptyForm)
|
||||
setErrors({})
|
||||
} else {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = "用户名不能为空"
|
||||
} else if (form.username.trim().length < 3) {
|
||||
newErrors.username = "用户名长度不能少于 3 位"
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
newErrors.password = "密码不能为空"
|
||||
} else if (form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await createUser({
|
||||
...form,
|
||||
username: form.username.trim(),
|
||||
nickname: form.nickname?.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are already handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
直接创建一个本地密码登录的新账户。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="请输入用户名 (至少 3 位)"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="请输入登录密码 (至少 8 位)"
|
||||
/>
|
||||
{errors.password && (
|
||||
<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>
|
||||
<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 }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Switch
|
||||
checked={form.is_admin}
|
||||
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}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} variant="secondary">
|
||||
{saving ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, 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 {cn, formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
selectedUser: AdminUser | null
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailLoading: boolean
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||
onDeleteTarget: (user: AdminUser) => void
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
selectedUser,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
onDeleteTarget,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
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>
|
||||
|
||||
{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">
|
||||
<AvatarImage src={selectedUser.avatar_url} />
|
||||
<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">
|
||||
UID: {selectedUser.id}
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading && (
|
||||
<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>
|
||||
</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" />
|
||||
邮箱
|
||||
</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" />
|
||||
手机
|
||||
</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>
|
||||
性别
|
||||
</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" />
|
||||
所在地
|
||||
</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" />
|
||||
网站
|
||||
</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>
|
||||
</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 ? "正常" : "禁用"}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
||||
selectedUser.is_active
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
onClick={() => onStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3 mr-1" />
|
||||
封禁账户
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="size-3 mr-1" />
|
||||
解除封禁
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 text-xs font-medium"
|
||||
onClick={() => onDeleteTarget(selectedUser)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除用户
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
export function UserFilterBar() {
|
||||
const {
|
||||
total,
|
||||
loading,
|
||||
page,
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setStatusFilter,
|
||||
fetchUsers
|
||||
} = useAdminUsers()
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
|
||||
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">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Search className="size-3 mr-1" />
|
||||
搜索
|
||||
{hasSearchFilter && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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..."
|
||||
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..."
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-6 text-xs"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
状态
|
||||
{statusFilter !== 'all' && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
<DropdownMenuItem
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(hasSearchFilter || statusFilter !== 'all') && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setStatusFilter('all')
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{total} 条记录
|
||||
</span>
|
||||
<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"
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<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"
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
<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}>
|
||||
{pageSize}条/页
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => setPageSize(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
onClick={() => fetchUsers(true)}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"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, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
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"
|
||||
|
||||
export default function UsersPage() {
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
fetchUsers,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
} = useAdminUsers()
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleStatusToggle = async (user: AdminUser) => {
|
||||
await updateUserStatus(user)
|
||||
|
||||
if (selectedUser?.id === user.id) {
|
||||
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
|
||||
try {
|
||||
const detail = await getUserDetail(user.id)
|
||||
setSelectedUser(detail)
|
||||
} catch {
|
||||
setSelectedUser(user)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await deleteUser(deleteTarget)
|
||||
if (selectedUser?.id === deleteTarget.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 筛选与分页栏 (已拆分为独立自治组件) */}
|
||||
<UserFilterBar />
|
||||
|
||||
{error ? (
|
||||
<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="加载用户列表中..." />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
|
||||
) : (
|
||||
<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-[140px] 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="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>
|
||||
</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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
查看详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!user.is_admin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户详情侧拉抽屉 (已拆分为独立子组件) */}
|
||||
<UserDetailSheet
|
||||
selectedUser={selectedUser}
|
||||
isOpen={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
detailLoading={detailLoading}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onDeleteTarget={setDeleteTarget}
|
||||
/>
|
||||
|
||||
{/* 删除确认警告弹窗 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
|
||||
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 新建用户模态弹窗 */}
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserFileManager />
|
||||
</motion.div>
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {HomeMain} from "@/components/common/home/home-main"
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeMain />
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"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
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const [isFullWidth, setIsFullWidth] = useState(false)
|
||||
|
||||
useAuthRedirect()
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen"
|
||||
style={
|
||||
{
|
||||
"--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"}`}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import {AccessTokenMain} from "@/components/common/settings/access-token"
|
||||
|
||||
export default function AccessTokenPage() {
|
||||
return <AccessTokenMain />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {AppearanceMain} from "@/components/common/settings/appearance"
|
||||
|
||||
export default function AppearancePage() {
|
||||
return <AppearanceMain />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import {RequireAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <RequireAuth>{children}</RequireAuth>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {NotificationsMain} from "@/components/common/settings/notifications"
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsMain />
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
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: "查看您的账户信息",
|
||||
icon: UserRound,
|
||||
href: "/settings/profile",
|
||||
category: "个人设置",
|
||||
},
|
||||
{
|
||||
title: "访问令牌 (AccessToken)",
|
||||
description: "管理用于 API 访问的个人 Token",
|
||||
icon: Key,
|
||||
href: "/settings/access-token",
|
||||
category: "个人设置",
|
||||
},
|
||||
{
|
||||
title: "通知设置",
|
||||
description: "设置您的通知偏好",
|
||||
icon: Bell,
|
||||
href: "/settings/notifications",
|
||||
category: "账户设置",
|
||||
},
|
||||
{
|
||||
title: "外观设置",
|
||||
description: "自定义界面主题 and 显示",
|
||||
icon: Palette,
|
||||
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>)
|
||||
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<CardContent>
|
||||
<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">
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ProfileMain} from "@/components/common/settings/profile"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileMain />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {redirect} from "next/navigation"
|
||||
|
||||
export default function SecurityPage() {
|
||||
redirect("/admin/settings")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(58.5% 51% 277.117);
|
||||
--primary-foreground: oklch(98.5% 0% 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/*
|
||||
* Aurora 动画配置
|
||||
*/
|
||||
--animate-aurora: aurora 120s linear infinite;
|
||||
|
||||
@keyframes aurora {
|
||||
from {
|
||||
background-position:
|
||||
50% 50%,
|
||||
50% 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position:
|
||||
350% 50%,
|
||||
350% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 红包动画配置
|
||||
*/
|
||||
--animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out;
|
||||
--animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite;
|
||||
--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); }
|
||||
}
|
||||
|
||||
@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); }
|
||||
}
|
||||
|
||||
@keyframes gold-particle {
|
||||
0% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
100% { transform: translateY(-100px) scale(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/*
|
||||
* 铃铛响铃动画配置
|
||||
* 使用微妙的摆动效果,优雅且不过度
|
||||
*/
|
||||
--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,0 +1,33 @@
|
||||
import {ImageResponse} from 'next/og'
|
||||
import {WavesIcon} from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export const size = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}
|
||||
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>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {Metadata} from "next";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wavelet Platform",
|
||||
description: "通用二次开发标准范式平台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className="hide-scrollbar font-sans"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className="hide-scrollbar font-sans antialiased"
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<AppQueryProvider>
|
||||
<SiteTitleUpdater />
|
||||
<RobotsMeta />
|
||||
<UserProvider>
|
||||
<NotificationSettingsProvider>
|
||||
<BellRingProvider>
|
||||
{children}
|
||||
<Toaster position="top-center" />
|
||||
</BellRingProvider>
|
||||
</NotificationSettingsProvider>
|
||||
</UserProvider>
|
||||
</AppQueryProvider>
|
||||
</CustomThemeProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
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
|
||||
* 当用户访问不存在的页面时显示
|
||||
*/
|
||||
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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<p className="text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed">
|
||||
抱歉,您访问的页面似已迷失在数字星云中。
|
||||
<br />
|
||||
请检查路径或返回安全地带。
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="size-3 opacity-70" />
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<Link href="/">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300"
|
||||
>
|
||||
<Home className="size-3" />
|
||||
首页
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 text-xs text-muted-foreground/30 font-mono">
|
||||
ERR_HTTP_NOT_FOUND
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
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">
|
||||
<HeroSection />
|
||||
<DeveloperSection />
|
||||
<FooterSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user