diff --git a/AGENTS.md b/AGENTS.md index 8adbbc5e..cec5c1fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ - 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。 - 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。 - 在完成代码开发后必须运行 `make code-check`, 并修复报错。 +- 在完成代码开发后/git 提交前必须运行 `make prettier`进行格式化。 - 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。 - 文件摄取必须通过 `upload.Ingest`(`upload.PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除必须通过 `upload.Remove` 或 `upload.RemoveOwned`。禁止业务模块直接调用 `repository.CreateUpload` / `repository.SoftDeleteUpload`,禁止 `db.Create(&model.Upload{})` 旁路写 `w_uploads`。 - 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送内置事件、域事件监听器、任务完成钩子)。统一通过 `internal/bootstrap` 在 `internal/cmd` 入口显式装配。 diff --git a/frontend/README.md b/frontend/README.md index 82b40cd8..a8db7751 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -22,11 +22,13 @@ Modern frontend application for the Wavelet scaffold framework. ### Installation 1. Install dependencies: + ```bash pnpm install ``` 2. Run the development server: + ```bash pnpm dev ``` diff --git a/frontend/README_zh.md b/frontend/README_zh.md index a4cd846a..e388a87a 100644 --- a/frontend/README_zh.md +++ b/frontend/README_zh.md @@ -22,11 +22,13 @@ Wavelet 脚手架系统的现代化前端应用。 ### 安装 1. 安装依赖: + ```bash pnpm install ``` 2. 运行开发服务器: + ```bash pnpm dev ``` diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 5c3f1f52..3ca722e9 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,10 +1,10 @@ -import { Suspense } from "react" -import { LoginPage } from "@/components/auth/login-page" +import { Suspense } from 'react'; +import { LoginPage } from '@/components/auth/login-page'; export default function Page() { return ( - ) + ); } diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx index 91ab280b..99812864 100644 --- a/frontend/app/(auth)/register/page.tsx +++ b/frontend/app/(auth)/register/page.tsx @@ -1,10 +1,10 @@ -import {Suspense} from "react" -import {RegisterPage} from "@/components/auth/register-page" +import { Suspense } from 'react'; +import { RegisterPage } from '@/components/auth/register-page'; export default function Page() { return ( - ) + ); } diff --git a/frontend/app/(docs)/docs/api/page.tsx b/frontend/app/(docs)/docs/api/page.tsx index 3daac034..26797fef 100644 --- a/frontend/app/(docs)/docs/api/page.tsx +++ b/frontend/app/(docs)/docs/api/page.tsx @@ -1,15 +1,17 @@ -import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"; -import {apiSections, DOCS_LAST_UPDATED} from "@/components/common/docs/api"; +import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; +import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api'; export default function ApiDocPage() { return ( - Wavelet 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。 +

+ Wavelet 提供简单、强大的 API + 接口,支持多种编程语言和开发环境。通过标准化的 RESTful + 接口,您可以轻松地与后端服务进行集成与二次开发。

} /> diff --git a/frontend/app/(docs)/docs/how-to-use/page.tsx b/frontend/app/(docs)/docs/how-to-use/page.tsx index 27e87ba5..94481331 100644 --- a/frontend/app/(docs)/docs/how-to-use/page.tsx +++ b/frontend/app/(docs)/docs/how-to-use/page.tsx @@ -1,16 +1,17 @@ -import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"; -import {howToUseSections} from "@/components/common/docs/how-to-use"; -import {DOCS_LAST_UPDATED} from "@/components/common/docs/api"; +import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; +import { howToUseSections } from '@/components/common/docs/how-to-use'; +import { DOCS_LAST_UPDATED } from '@/components/common/docs/api'; export default function HowToUsePage() { return ( - 欢迎使用 Wavelet 脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。 +

+ 欢迎使用 Wavelet + 脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。

} /> diff --git a/frontend/app/(docs)/docs/privacy-policy/page.tsx b/frontend/app/(docs)/docs/privacy-policy/page.tsx index 50902494..195ed2b8 100644 --- a/frontend/app/(docs)/docs/privacy-policy/page.tsx +++ b/frontend/app/(docs)/docs/privacy-policy/page.tsx @@ -1,20 +1,20 @@ -import { LegalPageLayout } from "@/components/common/docs/legal-page-layout" -import { privacySections } from "@/components/common/docs/privacy" -import { TERMS_LAST_UPDATED } from "@/components/common/docs/terms" +import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; +import { privacySections } from '@/components/common/docs/privacy'; +import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms'; export default function PrivacyPolicyPage() { return ( 我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息, -
+
以及您享有的相关权利。 } /> - ) + ); } diff --git a/frontend/app/(docs)/docs/terms-of-service/page.tsx b/frontend/app/(docs)/docs/terms-of-service/page.tsx index 982394fc..ecb24e91 100644 --- a/frontend/app/(docs)/docs/terms-of-service/page.tsx +++ b/frontend/app/(docs)/docs/terms-of-service/page.tsx @@ -1,17 +1,16 @@ -import { LegalPageLayout } from "@/components/common/docs/legal-page-layout" -import { termsSections, TERMS_LAST_UPDATED } from "@/components/common/docs/terms" +import { LegalPageLayout } from '@/components/common/docs/legal-page-layout'; +import { + TERMS_LAST_UPDATED, + termsSections, +} from '@/components/common/docs/terms'; export default function TermsOfServicePage() { return ( - 为了保障您的合法权益,请您仔细阅读以下条款。 - - } + description={为了保障您的合法权益,请您仔细阅读以下条款。} /> - ) + ); } diff --git a/frontend/app/(main)/admin/database/components/cache-manager.tsx b/frontend/app/(main)/admin/database/components/cache-manager.tsx index b0214fdf..ec09a178 100644 --- a/frontend/app/(main)/admin/database/components/cache-manager.tsx +++ b/frontend/app/(main)/admin/database/components/cache-manager.tsx @@ -1,16 +1,22 @@ -"use client" +'use client'; -import * as React from "react" -import {useCallback, useEffect, useState} from "react" -import {toast} from "sonner" -import {HardDrive, RefreshCw, Trash2} from "lucide-react" +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { HardDrive, RefreshCw, Trash2 } from 'lucide-react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Button} from "@/components/ui/button" -import {Skeleton} from "@/components/ui/skeleton" -import {Label} from "@/components/ui/label" -import {Input} from "@/components/ui/input" -import {Switch} from "@/components/ui/switch" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, @@ -18,183 +24,205 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" -import type {CacheStatus} from "@/lib/services/admin" -import services from "@/lib/services" +} from '@/components/ui/dialog'; +import type { CacheStatus } from '@/lib/services/admin'; +import services from '@/lib/services'; /** * 格式化字节大小 */ const formatBytes = (bytes: number) => { - if (bytes === 0) return "0 Bytes" - const k = 1024 - const sizes = ["Bytes", "KB", "MB", "GB", "TB"] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] -} + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; /** * 格式化数字,每3位加逗号 */ const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return "0" - return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",") -} + if (num === undefined || num === null) return '0'; + return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ','); +}; interface CacheManagerProps { - refreshTrigger: number + refreshTrigger: number; } export function CacheManager({ refreshTrigger }: CacheManagerProps) { - const [cacheStatus, setCacheStatus] = useState(null) - const [loadingCache, setLoadingCache] = useState(true) - const [savingConfig, setSavingConfig] = useState(false) - const [clearingCache, setClearingCache] = useState(false) - const [showClearConfirm, setShowClearConfirm] = useState(false) + const [cacheStatus, setCacheStatus] = useState(null); + const [loadingCache, setLoadingCache] = useState(true); + const [savingConfig, setSavingConfig] = useState(false); + const [clearingCache, setClearingCache] = useState(false); + const [showClearConfirm, setShowClearConfirm] = useState(false); // 策略配置表单状态 - const [maxSizeMB, setMaxSizeMB] = useState("100") - const [ttlMinutes, setTtlMinutes] = useState("60") - const [lruEnabled, setLruEnabled] = useState(true) + const [maxSizeMB, setMaxSizeMB] = useState('100'); + const [ttlMinutes, setTtlMinutes] = useState('60'); + const [lruEnabled, setLruEnabled] = useState(true); // 获取磁盘缓存状态 const fetchCacheStatus = useCallback(async (isSilent = false) => { - if (!isSilent) setLoadingCache(true) + if (!isSilent) setLoadingCache(true); try { - const data = await services.adminCache.getCacheStatus() - setCacheStatus(data) - setMaxSizeMB(data.max_size_mb.toString()) - setTtlMinutes(data.ttl_minutes.toString()) - setLruEnabled(data.lru_enabled) + const data = await services.adminCache.getCacheStatus(); + setCacheStatus(data); + setMaxSizeMB(data.max_size_mb.toString()); + setTtlMinutes(data.ttl_minutes.toString()); + setLruEnabled(data.lru_enabled); } catch (err) { - toast.error("获取磁盘缓存状态失败", { - description: err instanceof Error ? err.message : "未知错误", - }) + toast.error('获取磁盘缓存状态失败', { + description: err instanceof Error ? err.message : '未知错误', + }); } finally { - setLoadingCache(false) + setLoadingCache(false); } - }, []) + }, []); // 保存配置 const handleSaveConfig = async (e: React.FormEvent) => { - e.preventDefault() - const size = parseInt(maxSizeMB, 10) - const ttl = parseInt(ttlMinutes, 10) + e.preventDefault(); + const size = parseInt(maxSizeMB, 10); + const ttl = parseInt(ttlMinutes, 10); if (isNaN(size) || size < 1) { - toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" }) - return + toast.error('保存失败', { + description: '最大容量限制必须是大于等于 1 的整数', + }); + return; } if (isNaN(ttl) || ttl < 0) { - toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" }) - return + toast.error('保存失败', { + description: '默认过期时间必须是大于等于 0 的整数', + }); + return; } - setSavingConfig(true) + setSavingConfig(true); try { await services.adminCache.updateCacheConfig({ max_size_mb: size, ttl_minutes: ttl, lru_enabled: lruEnabled, - }) - toast.success("保存成功", { description: "磁盘缓存策略已热更新" }) - await fetchCacheStatus(true) + }); + toast.success('保存成功', { description: '磁盘缓存策略已热更新' }); + await fetchCacheStatus(true); } catch (err) { - toast.error("保存配置失败", { - description: err instanceof Error ? err.message : "未知错误", - }) + toast.error('保存配置失败', { + description: err instanceof Error ? err.message : '未知错误', + }); } finally { - setSavingConfig(false) + setSavingConfig(false); } - } + }; // 清空磁盘缓存数据 const handleClearCache = async () => { - setClearingCache(true) + setClearingCache(true); try { - await services.adminCache.clearCache() - toast.success("清空成功", { description: "缓存数据已全部清除" }) - setShowClearConfirm(false) - await fetchCacheStatus(true) + await services.adminCache.clearCache(); + toast.success('清空成功', { description: '缓存数据已全部清除' }); + setShowClearConfirm(false); + await fetchCacheStatus(true); } catch (err) { - toast.error("清空缓存失败", { - description: err instanceof Error ? err.message : "未知错误", - }) + toast.error('清空缓存失败', { + description: err instanceof Error ? err.message : '未知错误', + }); } finally { - setClearingCache(false) + setClearingCache(false); } - } + }; // 初始化拉取 useEffect(() => { - fetchCacheStatus() - }, [fetchCacheStatus]) + fetchCacheStatus(); + }, [fetchCacheStatus]); // 当外部刷新触发器改变时自动刷新 useEffect(() => { - fetchCacheStatus(true) - }, [refreshTrigger, fetchCacheStatus]) + fetchCacheStatus(true); + }, [refreshTrigger, fetchCacheStatus]); return ( - - -
-
- - 缓存管理 + + +
+
+ + 缓存管理
- 管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略 + + 管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略 +
- + {loadingCache && !cacheStatus ? ( -
- - - +
+ + +
) : cacheStatus ? ( -
+
{/* 左边:状态区 (2/5 cols) */} -
-

运行状态

-
+
+

+ 运行状态 +

+
{/* 已占空间 */} -
-

已用空间

-

+

+

+ 已用空间 +

+

{formatBytes(cacheStatus.total_size)}

{/* Key数量 */} -
-

缓存键数量

-

- {formatNumber(cacheStatus.keys_count)} 个文件 +

+

+ 缓存键数量 +

+

+ {formatNumber(cacheStatus.keys_count)}{' '} + + 个文件 +

{/* 存储路径 */} -
-

缓存基准目录

- +
+

+ 缓存基准目录 +

+ {cacheStatus.base_path}
{/* 右边:配置区 (3/5 cols) */} -
-

策略配置

-
-
-
- +
+

+ 策略配置 +

+ +
+
+ setMaxSizeMB(e.target.value)} - className="h-8 text-xs bg-background/50 border-border/40" - placeholder="例如 100" + className='h-8 text-xs bg-background/50 border-border/40' + placeholder='例如 100' required /> -

当总大小超出该值时,自动触发淘汰算法

+

+ 当总大小超出该值时,自动触发淘汰算法 +

-
- +
+ setTtlMinutes(e.target.value)} - className="h-8 text-xs bg-background/50 border-border/40" - placeholder="例如 60,0 表示永不过期" + className='h-8 text-xs bg-background/50 border-border/40' + placeholder='例如 60,0 表示永不过期' required /> -

缓存项的最长存活时间,超期后将失效被清理

+

+ 缓存项的最长存活时间,超期后将失效被清理 +

-
-
-
) : ( -
- - 未加载到缓存状态信息 +
+ + 未加载到缓存状态信息
)} {/* 清除缓存确认对话框 */} - + - - + + 确认清空所有缓存? - - 该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。 + + 该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 + 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。 - + - ) + ); } diff --git a/frontend/app/(main)/admin/database/components/sql-console.tsx b/frontend/app/(main)/admin/database/components/sql-console.tsx index 2d896534..97e6be68 100644 --- a/frontend/app/(main)/admin/database/components/sql-console.tsx +++ b/frontend/app/(main)/admin/database/components/sql-console.tsx @@ -1,182 +1,210 @@ -"use client" +'use client'; -import * as React from "react" -import {useRef, useState} from "react" -import {useTheme} from "next-themes" -import CodeMirror from "@uiw/react-codemirror" -import {sql} from "@codemirror/lang-sql" -import {toast} from "sonner" -import {ArrowLeft, Play, RefreshCw, Terminal, Trash2,} from "lucide-react" +import * as React from 'react'; +import { useRef, useState } from 'react'; +import { useTheme } from 'next-themes'; +import CodeMirror from '@uiw/react-codemirror'; +import { sql } from '@codemirror/lang-sql'; +import { toast } from 'sonner'; +import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react'; -import {Button} from "@/components/ui/button" -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" -import type {ExecuteSQLResponse} from "@/lib/services/db-manage" -import {DbManageService} from "@/lib/services/db-manage" +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ExecuteSQLResponse } from '@/lib/services/db-manage'; +import { DbManageService } from '@/lib/services/db-manage'; /** * 格式化表格单元格内容 */ const formatCellValue = (val: unknown): string => { - if (val === null || val === undefined) return "-" - if (typeof val === "boolean") return val ? "true" : "false" - if (typeof val === "object") { + if (val === null || val === undefined) return '-'; + if (typeof val === 'boolean') return val ? 'true' : 'false'; + if (typeof val === 'object') { try { - return JSON.stringify(val) + return JSON.stringify(val); } catch { - return "[Object]" + return '[Object]'; } } - return String(val) -} + return String(val); +}; interface SQLConsoleProps { - dbType?: string // "postgres" | "sqlite" - onClose: () => void + dbType?: string; // "postgres" | "sqlite" + onClose: () => void; } export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { // SQL 控制台状态 - const [sqlQuery, setSqlQuery] = useState("") - const [executingSQL, setExecutingSQL] = useState(false) - const [sqlResult, setSqlResult] = useState(null) - const [sqlError, setSqlError] = useState(null) + const [sqlQuery, setSqlQuery] = useState(''); + const [executingSQL, setExecutingSQL] = useState(false); + const [sqlResult, setSqlResult] = useState(null); + const [sqlError, setSqlError] = useState(null); // 拖拽与主题状态 - const containerRef = useRef(null) - const [editorHeight, setEditorHeight] = useState(240) - const { resolvedTheme } = useTheme() - const cmTheme = resolvedTheme === "dark" ? "dark" : "light" + const containerRef = useRef(null); + const [editorHeight, setEditorHeight] = useState(240); + const { resolvedTheme } = useTheme(); + const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault() - const startY = e.clientY - const startHeight = editorHeight + e.preventDefault(); + const startY = e.clientY; + const startHeight = editorHeight; const handleMouseMove = (moveEvent: MouseEvent) => { - if (!containerRef.current) return - const deltaY = moveEvent.clientY - startY - const containerHeight = containerRef.current.getBoundingClientRect().height - const newHeight = startHeight + deltaY + if (!containerRef.current) return; + const deltaY = moveEvent.clientY - startY; + const containerHeight = + containerRef.current.getBoundingClientRect().height; + const newHeight = startHeight + deltaY; // Limit editor height between 80px and containerHeight - 80px if (newHeight > 80 && newHeight < containerHeight - 80) { - setEditorHeight(newHeight) + setEditorHeight(newHeight); } - } + }; const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove) - document.removeEventListener("mouseup", handleMouseUp) - } + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; - document.addEventListener("mousemove", handleMouseMove) - document.addEventListener("mouseup", handleMouseUp) - } + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; // 执行 SQL 查询 const handleExecuteSQL = async () => { - if (!sqlQuery.trim()) return - setExecutingSQL(true) - setSqlResult(null) - setSqlError(null) + if (!sqlQuery.trim()) return; + setExecutingSQL(true); + setSqlResult(null); + setSqlError(null); try { - const result = await DbManageService.executeSQL(sqlQuery) - setSqlResult(result) - toast.success("SQL 执行成功") + const result = await DbManageService.executeSQL(sqlQuery); + setSqlResult(result); + toast.success('SQL 执行成功'); } catch (err) { - setSqlError(err instanceof Error ? err.message : "未知执行错误") - toast.error("SQL 执行失败") + setSqlError(err instanceof Error ? err.message : '未知执行错误'); + toast.error('SQL 执行失败'); } finally { - setExecutingSQL(false) + setExecutingSQL(false); } - } + }; // Preset SQL 选择器 const handlePresetSQLChange = (query: string) => { - setSqlQuery(query) - } + setSqlQuery(query); + }; const handleClose = () => { - setSqlResult(null) - setSqlError(null) - onClose() - } + setSqlResult(null); + setSqlError(null); + onClose(); + }; return ( -
+
{/* 顶部控制与标题 */} -
-
+
+
-
- +
+
-

SQL 查询终端

+

+ SQL 查询终端 +

-
- {dbType === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"} +
+ {dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
{/* 顶部编辑工具栏 */} -
-
- - SQL 编辑器 +
+
+ + SQL 编辑器
-
+
{/* 快速模板 */} -
- 快速模板: +
+ + 快速模板: +
{/* 控制按钮 */} -
+
@@ -185,15 +213,15 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { {/* 上半区:编辑器输入框 */}
setSqlQuery(value)} - className="h-full text-xs font-mono" + className='h-full text-xs font-mono' basicSetup={{ lineNumbers: true, foldGutter: true, @@ -207,82 +235,107 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { {/* 拖动分割线 */}
-
+
{/* 下半区:查看结果 (可滑动查看) */} -
+
{/* 结果栏工具提示 */} -
- 执行输出 +
+ 执行输出 {sqlResult && ( - 类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms + 类型: {sqlResult.type.toUpperCase()} | 耗时:{' '} + {sqlResult.execution_time_ms} ms )}
{/* 滚动结果内容 */} -
+
{executingSQL && ( -
- - 正在在数据库执行查询,请稍候... +
+ + + 正在在数据库执行查询,请稍候... +
)} {sqlError && ( -
-

SQL 执行报错 (Error):

-
{sqlError}
+
+

SQL 执行报错 (Error):

+
{sqlError}
)} {sqlResult && ( -
- {sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && ( -
- - - - {sqlResult.columns.map((col) => ( - {col} - ))} - - - - {sqlResult.results && sqlResult.results.length > 0 ? ( - sqlResult.results.map((row, rIndex) => ( - - {sqlResult.columns!.map((col) => ( - - - {formatCellValue(row[col])} - - - ))} - - )) - ) : ( +
+ {sqlResult.type === 'select' && + sqlResult.columns && + sqlResult.columns.length > 0 && ( +
+
+ - - 查询结果为空 - + {sqlResult.columns.map((col) => ( + + {col} + + ))} - )} - -
-
- )} + + + {sqlResult.results && sqlResult.results.length > 0 ? ( + sqlResult.results.map((row, rIndex) => ( + + {sqlResult.columns!.map((col) => ( + + + {formatCellValue(row[col])} + + + ))} + + )) + ) : ( + + + 查询结果为空 + + + )} + + +
+ )} - {sqlResult.type === "exec" && ( -
-

SQL 执行成功

-

- 受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。 + {sqlResult.type === 'exec' && ( +

+

+ SQL 执行成功 +

+

+ 受影响行数: {sqlResult.affected_rows} 行,耗时{' '} + {sqlResult.execution_time_ms} 毫秒。

)} @@ -290,14 +343,16 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { )} {!executingSQL && !sqlResult && !sqlError && ( -
- - 编辑器就绪,请在上方编写 SQL 并运行查看结果 +
+ + + 编辑器就绪,请在上方编写 SQL 并运行查看结果 +
)}
- ) + ); } diff --git a/frontend/app/(main)/admin/database/components/table-browser.tsx b/frontend/app/(main)/admin/database/components/table-browser.tsx index 9a73f308..fe63a2d9 100644 --- a/frontend/app/(main)/admin/database/components/table-browser.tsx +++ b/frontend/app/(main)/admin/database/components/table-browser.tsx @@ -1,157 +1,195 @@ -"use client" +'use client'; -import * as React from "react" -import {useCallback, useEffect, useState} from "react" -import {toast} from "sonner" -import {Layers, RefreshCw} from "lucide-react" +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Layers, RefreshCw } from 'lucide-react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Skeleton} from "@/components/ui/skeleton" -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" -import {Button} from "@/components/ui/button" -import type {TableDataResponse} from "@/lib/services/db-manage" -import {DbManageService} from "@/lib/services/db-manage" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import type { TableDataResponse } from '@/lib/services/db-manage'; +import { DbManageService } from '@/lib/services/db-manage'; /** * 格式化数字,每3位加逗号 */ const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return "0" - return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",") -} + if (num === undefined || num === null) return '0'; + return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ','); +}; /** * 格式化表格单元格内容 */ const formatCellValue = (val: unknown): string => { - if (val === null || val === undefined) return "-" - if (typeof val === "boolean") return val ? "true" : "false" - if (typeof val === "object") { + if (val === null || val === undefined) return '-'; + if (typeof val === 'boolean') return val ? 'true' : 'false'; + if (typeof val === 'object') { try { - return JSON.stringify(val) + return JSON.stringify(val); } catch { - return "[Object]" + return '[Object]'; } } - return String(val) -} + return String(val); +}; interface TableBrowserProps { - tables: string[] - loadingTables: boolean - refreshTrigger: number + tables: string[]; + loadingTables: boolean; + refreshTrigger: number; } -export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) { - const [selectedTable, setSelectedTable] = useState("") - const [tableData, setTableData] = useState(null) - const [page, setPage] = useState(1) - const [loadingData, setLoadingData] = useState(false) - const pageSize = 10 +export function TableBrowser({ + tables, + loadingTables, + refreshTrigger, +}: TableBrowserProps) { + const [selectedTable, setSelectedTable] = useState(''); + const [tableData, setTableData] = useState(null); + const [page, setPage] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const pageSize = 10; // 获取具体表数据 - const fetchTableData = useCallback(async (tableName: string, targetPage: number, size: number) => { - if (!tableName) return - setLoadingData(true) - try { - const data = await DbManageService.getTableData({ - table: tableName, - page: targetPage, - pageSize: size, - }) - setTableData(data) - } catch (err) { - toast.error(`获取数据表 ${tableName} 数据失败`, { - description: err instanceof Error ? err.message : "未知错误", - }) - } finally { - setLoadingData(false) - } - }, []) + const fetchTableData = useCallback( + async (tableName: string, targetPage: number, size: number) => { + if (!tableName) return; + setLoadingData(true); + try { + const data = await DbManageService.getTableData({ + table: tableName, + page: targetPage, + pageSize: size, + }); + setTableData(data); + } catch (err) { + toast.error(`获取数据表 ${tableName} 数据失败`, { + description: err instanceof Error ? err.message : '未知错误', + }); + } finally { + setLoadingData(false); + } + }, + [], + ); // 默认选择第一张表 useEffect(() => { if (tables.length > 0 && !selectedTable) { - setSelectedTable(tables[0]) + setSelectedTable(tables[0]); } - }, [tables, selectedTable]) + }, [tables, selectedTable]); // 当选中的表、页码或外部刷新触发器改变时获取数据 useEffect(() => { if (selectedTable) { - fetchTableData(selectedTable, page, pageSize) + fetchTableData(selectedTable, page, pageSize); } - }, [selectedTable, page, pageSize, refreshTrigger, fetchTableData]) + }, [selectedTable, page, pageSize, refreshTrigger, fetchTableData]); // 处理页码改变 const handlePageChange = (newPage: number) => { - setPage(newPage) - } + setPage(newPage); + }; // 处理表切换 const handleTableChange = (value: string) => { - setSelectedTable(value) - setPage(1) // 切换表重置为第一页 - } + setSelectedTable(value); + setPage(1); // 切换表重置为第一页 + }; return ( - - -
- 数据表浏览器 - 浏览数据库中的物理数据表详情及内容 + + +
+ 数据表浏览器 + + 浏览数据库中的物理数据表详情及内容 +
-
+
{loadingTables ? ( - + ) : ( )}
- + {loadingData && !tableData ? ( -
- - - - +
+ + + +
) : tableData && tableData.columns.length > 0 ? ( -
+
{/* 数据表格区域 */} -
+
{loadingData && ( -
- +
+
)} - - +
+ {tableData.columns.map((col) => ( - {col} + + {col} + ))} {tableData.results && tableData.results.length > 0 ? ( tableData.results.map((row, rIndex) => ( - + {tableData.columns.map((col) => ( - - + + {formatCellValue(row[col])} @@ -160,7 +198,10 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro )) ) : ( - + 表中无记录数据 @@ -170,29 +211,36 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro {/* 分页控制 */} -
+
- 总行数: {formatNumber(tableData.total)} 条记录 + 总行数:{' '} + + {formatNumber(tableData.total)} + {' '} + 条记录
-
+
- - 第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页 + + 第 {page} /{' '} + {Math.max(1, Math.ceil(tableData.total / pageSize))} 页 @@ -200,12 +248,12 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
) : ( -
- - 未选择数据表或表结构无法加载 +
+ + 未选择数据表或表结构无法加载
)} - ) + ); } diff --git a/frontend/app/(main)/admin/database/page-client.tsx b/frontend/app/(main)/admin/database/page-client.tsx index cdd1f579..a8fbc284 100644 --- a/frontend/app/(main)/admin/database/page-client.tsx +++ b/frontend/app/(main)/admin/database/page-client.tsx @@ -1,9 +1,9 @@ -"use client" +'use client'; -import dynamic from "next/dynamic" -import * as React from "react" -import {useCallback, useEffect, useState} from "react" -import {toast} from "sonner" +import dynamic from 'next/dynamic'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { Activity, Cpu, @@ -15,134 +15,142 @@ import { RefreshCw, Server, Terminal, -} from "lucide-react" +} from 'lucide-react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Button} from "@/components/ui/button" -import {Skeleton} from "@/components/ui/skeleton" -import services from "@/lib/services" -import type {DBOverview} from "@/lib/services/db-manage" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import services from '@/lib/services'; +import type { DBOverview } from '@/lib/services/db-manage'; const sectionFallback = ( -
-) +
+); const TableBrowser = dynamic( - () => import("./components/table-browser").then((mod) => mod.TableBrowser), + () => import('./components/table-browser').then((mod) => mod.TableBrowser), { loading: () => sectionFallback }, -) +); const CacheManager = dynamic( - () => import("./components/cache-manager").then((mod) => mod.CacheManager), + () => import('./components/cache-manager').then((mod) => mod.CacheManager), { loading: () => sectionFallback }, -) +); const SQLConsole = dynamic( - () => import("./components/sql-console").then((mod) => mod.SQLConsole), + () => import('./components/sql-console').then((mod) => mod.SQLConsole), { ssr: false }, -) +); /** * 格式化数字,每3位加逗号 */ const formatNumber = (num: number | string) => { - if (num === undefined || num === null) return "0" - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") -} + if (num === undefined || num === null) return '0'; + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +}; export function DatabasePageClient() { // 核心状态 - const [overview, setOverview] = useState(null) - const [tables, setTables] = useState([]) - const [loadingOverview, setLoadingOverview] = useState(true) - const [loadingTables, setLoadingTables] = useState(true) - const [exporting, setExporting] = useState(false) + const [overview, setOverview] = useState(null); + const [tables, setTables] = useState([]); + const [loadingOverview, setLoadingOverview] = useState(true); + const [loadingTables, setLoadingTables] = useState(true); + const [exporting, setExporting] = useState(false); // 协调子组件刷新与视图切换状态 - const [showConsole, setShowConsole] = useState(false) - const [refreshTrigger, setRefreshTrigger] = useState(0) + const [showConsole, setShowConsole] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); // 1. 获取运行概览 const fetchOverview = useCallback(async (isSilent = false) => { - if (!isSilent) setLoadingOverview(true) + if (!isSilent) setLoadingOverview(true); try { - const data = await services.dbManage.getOverview() - setOverview(data) + const data = await services.dbManage.getOverview(); + setOverview(data); } catch (err) { - toast.error("获取数据库概览失败", { - description: err instanceof Error ? err.message : "未知错误", - }) + toast.error('获取数据库概览失败', { + description: err instanceof Error ? err.message : '未知错误', + }); } finally { - setLoadingOverview(false) + setLoadingOverview(false); } - }, []) + }, []); // 2. 获取表列表 const fetchTables = useCallback(async () => { - setLoadingTables(true) + setLoadingTables(true); try { - const data = await services.dbManage.listTables() - setTables(data) + const data = await services.dbManage.listTables(); + setTables(data); } catch (err) { - toast.error("获取数据库数据表列表失败", { - description: err instanceof Error ? err.message : "未知错误", - }) + toast.error('获取数据库数据表列表失败', { + description: err instanceof Error ? err.message : '未知错误', + }); } finally { - setLoadingTables(false) + setLoadingTables(false); } - }, []) + }, []); // 3. 协调刷新 const handleRefreshAll = () => { - fetchOverview() - fetchTables() - setRefreshTrigger((prev) => prev + 1) - } + fetchOverview(); + fetchTables(); + setRefreshTrigger((prev) => prev + 1); + }; // 4. 导出数据库备份 const handleExport = async () => { - setExporting(true) + setExporting(true); try { - const { blob, filename } = await services.dbManage.exportDatabase() - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) - toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` }) + const { blob, filename } = await services.dbManage.exportDatabase(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success('数据库导出成功', { + description: `已下载归档文件: ${filename}`, + }); } catch (err) { - toast.error("数据库导出失败", { - description: err instanceof Error ? err.message : "导出异常", - }) + toast.error('数据库导出失败', { + description: err instanceof Error ? err.message : '导出异常', + }); } finally { - setExporting(false) + setExporting(false); } - } + }; // 初始化加载 useEffect(() => { - fetchOverview() - fetchTables() - }, [fetchOverview, fetchTables]) + fetchOverview(); + fetchTables(); + }, [fetchOverview, fetchTables]); // 渲染概览卡片骨架 const renderOverviewSkeleton = () => ( -
+
{Array.from({ length: 6 }).map((_, i) => ( - - - + + + - + ))}
- ) + ); // 渲染 SQL 查询控台全屏视图 if (showConsole) { @@ -151,110 +159,136 @@ export function DatabasePageClient() { dbType={overview?.type} onClose={() => setShowConsole(false)} /> - ) + ); } // 正常页面排版布局 return ( -
+
{/* 顶部控制与标题 */} -
-
- +
+
+
-

数据管理

+

数据管理

{/* 1. 概览指标卡片组 */} - {loadingOverview ? ( - renderOverviewSkeleton() - ) : ( - overview && ( -
- {/* 卡片1: 数据库类型 */} - - - 数据库类型 - - - -
{overview.type}
-
-
+ {loadingOverview + ? renderOverviewSkeleton() + : overview && ( +
+ {/* 卡片1: 数据库类型 */} + + + + 数据库类型 + + + + +
+ {overview.type} +
+
+
- {/* 卡片2: 数据库版本 */} - - - 版本信息 - - - -
- {overview.version.split(" ").slice(0, 2).join(" ")} -
-
-
+ {/* 卡片2: 数据库版本 */} + + + + 版本信息 + + + + +
+ {overview.version.split(' ').slice(0, 2).join(' ')} +
+
+
- {/* 卡片3: 数据库名称 */} - - - 名称/路径 - - - -
- {overview.name.substring(overview.name.lastIndexOf("/") + 1)} -
-
-
+ {/* 卡片3: 数据库名称 */} + + + + 名称/路径 + + + + +
+ {overview.name.substring( + overview.name.lastIndexOf('/') + 1, + )} +
+
+
- {/* 卡片4: 数据库大小 */} - - - 数据库大小 - - - -
{overview.size}
-
-
+ {/* 卡片4: 数据库大小 */} + + + + 数据库大小 + + + + +
{overview.size}
+
+
- {/* 卡片5: 表数量 */} - - - 物理数据表 - - - -
{formatNumber(overview.table_count)}
-
-
+ {/* 卡片5: 表数量 */} + + + + 物理数据表 + + + + +
+ {formatNumber(overview.table_count)} +
+
+
- {/* 卡片6: 连接数 */} - - - 活跃连接数 - - - -
{formatNumber(overview.connections)}
-
-
-
- ) - )} + {/* 卡片6: 连接数 */} + + + + 活跃连接数 + + + + +
+ {formatNumber(overview.connections)} +
+
+
+
+ )} {/* 2. 数据表浏览器区块 */} {/* 3. 缓存管理区块 */} - + {/* 4. 底部功能卡片区 */} - - - 功能区 - 数据库导出及自定义高级 SQL 执行终端 + + + 功能区 + + 数据库导出及自定义高级 SQL 执行终端 + - -
+ +
{/* 功能一: 数据库导出 */} -
-
-

- +

+
+

+ 数据库备份导出

-

- 直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件) +

+ 直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL + 导出为打包的 .sql 文本文件)

{/* 功能二: SQL 查询终端 */} -
-
-

- +

+
+

+ SQL 查询控台

-

- 打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化 +

+ 打开在线 SQL 控制终端,执行原生的 SQL + 进行数据筛选、更新、调试或性能优化

@@ -325,5 +363,5 @@ export function DatabasePageClient() {
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/(main)/admin/database/page.tsx b/frontend/app/(main)/admin/database/page.tsx index 24d40dea..fb3afdd0 100644 --- a/frontend/app/(main)/admin/database/page.tsx +++ b/frontend/app/(main)/admin/database/page.tsx @@ -1,5 +1,5 @@ -import {DatabasePageClient} from "./page-client" +import { DatabasePageClient } from './page-client'; export default function DatabasePage() { - return -} \ No newline at end of file + return ; +} diff --git a/frontend/app/(main)/admin/demo/page.tsx b/frontend/app/(main)/admin/demo/page.tsx index f35a5b32..9dad10a9 100644 --- a/frontend/app/(main)/admin/demo/page.tsx +++ b/frontend/app/(main)/admin/demo/page.tsx @@ -1,82 +1,176 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -"use client" +'use client'; -import * as React from "react" -import {Code} from "lucide-react" +import * as React from 'react'; +import { Code } from 'lucide-react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; // 引入模块化拆分后的 Tab 子组件 -import {DashboardTab} from "./tabs/dashboard-tab" -import {TableTab} from "./tabs/table-tab" -import {ControlsTab} from "./tabs/controls-tab" +import { DashboardTab } from './tabs/dashboard-tab'; +import { TableTab } from './tabs/table-tab'; +import { ControlsTab } from './tabs/controls-tab'; export default function HeaderDemoPage() { - const [activeTab, setActiveTab] = React.useState("dashboard") + const [activeTab, setActiveTab] = React.useState('dashboard'); return ( -
+
{/* 1. 标准页面标题 */} -
- +
+
-

开发规范示例

+

+ 开发规范示例 +

{/* 说明区域 */} - - -
- - 规范说明 + + +
+ + 规范说明
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
- -
-

核心规范细节与编写原则:

-
    -
  • 结构专注:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
  • -
  • 容器与上边距对齐:使用 flex items-center gap-2 作为标题行基础容器(不含下边框),外层统一使用 py-6 px-1 (或 py-6) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
  • -
  • 图标呈现:直接将 Lucide 图标组件嵌套于标题容器中,应用 size-5 text-primary 样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
  • -
  • 文本样式标准:文本使用且仅使用 {'h1 className="text-2xl font-semibold tracking-tight"'},禁用字重加粗(如 font-bold)或任何渐变色,保证全站字体一致性。
  • -
  • Tabs 模块化拆分:凡是带有多个 Tab 页切换的页面,**禁止**将所有 Tab 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 Content 必须单独拆分为独立组件文件(如 tabs/events-tab.tsx 或就近放在 components/ 下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
  • -
  • 复杂度驱动的区块拆分:不仅是 Tabs 切换,当一个页面代码行数超过 600 行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如 app/(main)/admin/database/components/),只有真正跨页面复用的组件才进入全局 components/common/。典型标杆案例参考“数据管理” (/admin/database) 的 table-browser.tsx、cache-manager.tsx 与 sql-console.tsx。
  • + +
    +

    + 核心规范细节与编写原则: +

    +
      +
    • + 结构专注 + :页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。 +
    • +
    • + 容器与上边距对齐:使用{' '} + + flex items-center gap-2 + {' '} + 作为标题行基础容器(不含下边框),外层统一使用{' '} + + py-6 px-1 + {' '} + (或{' '} + + py-6 + + ) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。 +
    • +
    • + 图标呈现:直接将 Lucide + 图标组件嵌套于标题容器中,应用{' '} + + size-5 text-primary + {' '} + 样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。 +
    • +
    • + 文本样式标准:文本使用且仅使用{' '} + + {'h1 className="text-2xl font-semibold tracking-tight"'} + + ,禁用字重加粗(如{' '} + + font-bold + + )或任何渐变色,保证全站字体一致性。 +
    • +
    • + Tabs 模块化拆分:凡是带有多个 Tab + 页切换的页面,**禁止**将所有 Tab + 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 + Content 必须单独拆分为独立组件文件(如{' '} + + tabs/events-tab.tsx + {' '} + 或就近放在{' '} + + components/ + {' '} + 下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。 +
    • +
    • + 复杂度驱动的区块拆分:不仅是 Tabs + 切换,当一个页面代码行数超过 600 + 行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '} + + app/(main)/admin/database/components/ + + ),只有真正跨页面复用的组件才进入全局{' '} + + components/common/ + + 。典型标杆案例参考“数据管理” ( + + /admin/database + + ) 的{' '} + + table-browser.tsx + + 、 + + cache-manager.tsx + {' '} + 与{' '} + + sql-console.tsx + + 。 +
    {/* Tabs 切换 */} - - - + + + 仪表盘与指标卡片 - + 数据表格规范 - + 输入控件与表单 - + - + - +
- ) + ); } diff --git a/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx b/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx index 5535365d..30702f78 100644 --- a/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx @@ -1,100 +1,148 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -"use client" +'use client'; -import * as React from "react" -import {Code} from "lucide-react" -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Label} from "@/components/ui/label" -import {Input} from "@/components/ui/input" -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" -import {Checkbox} from "@/components/ui/checkbox" -import {Button} from "@/components/ui/button" +import * as React from 'react'; +import { Code } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; export function ControlsTab() { const [formData, setFormData] = React.useState({ - name: "", - category: "database", + name: '', + category: 'database', allowNotify: true, - channel: "all", - }) + channel: 'all', + }); return ( -
+
{/* 控件展示 */} - - - 表单与输入框交互演示 - 包含按钮变体、下拉选择、多选框及基础输入框的标准样式 + + + + 表单与输入框交互演示 + + + 包含按钮变体、下拉选择、多选框及基础输入框的标准样式 + - + {/* 输入框 */} -
- +
+ setFormData(prev => ({ ...prev, name: e.target.value }))} - placeholder="输入要创建的任务描述..." - className="h-8 text-xs shadow-none bg-background focus-visible:ring-1" + onChange={(e) => + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + placeholder='输入要创建的任务描述...' + className='h-8 text-xs shadow-none bg-background focus-visible:ring-1' /> -

任务名称将用于后台执行记录的可读性显示

+

+ 任务名称将用于后台执行记录的可读性显示 +

{/* 下拉框 */} -
- +
+
{/* 多选框 Checkbox */} -
+
setFormData(prev => ({ ...prev, allowNotify: checked === true }))} - className="mt-0.5" + onCheckedChange={(checked) => + setFormData((prev) => ({ + ...prev, + allowNotify: checked === true, + })) + } + className='mt-0.5' /> -
+
-

+

勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。

{/* 按钮变体 */} -
-
按钮样式对齐:
-
- - - -
@@ -103,38 +151,84 @@ export function ControlsTab() { {/* 控件设计规范说明 */} - - - - + + + + 表单设计规范 - -
-

1. 统一控件高度与投影:

+ +
+

+ 1. 统一控件高度与投影: +

- 输入框 (Input) 与下拉选择器 (SelectTrigger) 的高度应统一限制为小号高度 h-8,字体大小使用 text-xs,并使用 shadow-none 去除原生的重叠阴影。 + 输入框 ( + + Input + + ) 与下拉选择器 ( + + SelectTrigger + + ) 的高度应统一限制为小号高度{' '} + + h-8 + + ,字体大小使用{' '} + + text-xs + + ,并使用{' '} + + shadow-none + {' '} + 去除原生的重叠阴影。

-

2. Checkbox 与文本的对齐:

+

+ 2. Checkbox 与文本的对齐: +

- Checkbox 应在左侧垂直偏上对齐,使用容器 flex items-start gap-2.5 排列。Checkbox 组件本身添加 mt-0.5,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。 + Checkbox 应在左侧垂直偏上对齐,使用容器{' '} + + flex items-start gap-2.5 + {' '} + 排列。Checkbox 组件本身添加{' '} + + mt-0.5 + + ,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。

-

3. 按钮使用策略:

+

+ 3. 按钮使用策略: +

- 普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要 {'variant="secondary"'} 按钮,小号高度统一采用 h-8 text-xs。线框按钮必须带有 border-dashed shadow-none 类。 + 普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '} + + {'variant="secondary"'} + {' '} + 按钮,小号高度统一采用{' '} + + h-8 text-xs + + 。线框按钮必须带有{' '} + + border-dashed shadow-none + {' '} + 类。

-
+
{`// 1. Label 与 Input 对齐`}
{`
`}
-
{`// 2. Checkbox 顶对齐`}
+
{`// 2. Checkbox 顶对齐`}
{`
@@ -146,5 +240,5 @@ export function ControlsTab() {
- ) + ); } diff --git a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx index a02a68b2..fcc81455 100644 --- a/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx @@ -1,61 +1,81 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -"use client" +'use client'; -import * as React from "react" -import {Code, Cpu, Database, HardDrive} from "lucide-react" -import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card" -import {Progress} from "@/components/ui/progress" +import * as React from 'react'; +import { Code, Cpu, Database, HardDrive } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; export function DashboardTab() { return ( -
-
+
+
{/* CPU */} - - - 容器 CPU 使用率 - + + + + 容器 CPU 使用率 + + - -
42.8 %
- -

- + +

42.8 %
+ +

+ 容器运行正常,负载平稳

{/* Storage */} - - - 存储已用容量 - + + + + 存储已用容量 + + - -
72.4 GB / 100 GB
- -

+ +

+ 72.4 GB{' '} + / 100 GB +
+ +

数据已同步远端 S3 存储桶,支持自动过期

{/* DB Overview */} - - - 物理数据库 - + + + + 物理数据库 + + - -
PostgreSQL (v16.2)
-
-
活跃连接: 12
-
总表数量: 34
+ +
+ PostgreSQL{' '} + + (v16.2) +
-

+

+
+ 活跃连接:{' '} + 12 +
+
+ 总表数量:{' '} + 34 +
+
+

链接池自动维护中,最大连接限制为 100

@@ -63,23 +83,53 @@ export function DashboardTab() {
{/* 指标卡片规范与代码 */} - - - - + + + + 指标卡片 & 仪表盘设计规范 - -
    -
  • 指标卡片统一采用 border-dashed shadow-none 作为边框和投影。
  • -
  • 头部使用 flex flex-row items-center justify-between pb-2 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
  • -
  • 数值强调使用且仅使用 text-2xl font-semibold tracking-tight 展示。
  • -
  • 进度条使用 h-1.5 细条高度,不喧宾夺主。
  • -
  • 底部的次要说明信息必须统一使用 text-[10px] text-muted-foreground 等辅助类。
  • + +
      +
    • + 指标卡片统一采用{' '} + + border-dashed shadow-none + {' '} + 作为边框和投影。 +
    • +
    • + 头部使用{' '} + + flex flex-row items-center justify-between pb-2 + {' '} + 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。 +
    • +
    • + 数值强调使用且仅使用{' '} + + text-2xl font-semibold tracking-tight + {' '} + 展示。 +
    • +
    • + 进度条使用{' '} + + h-1.5 + {' '} + 细条高度,不喧宾夺主。 +
    • +
    • + 底部的次要说明信息必须统一使用{' '} + + text-[10px] text-muted-foreground + {' '} + 等辅助类。 +
    -
    -{`
    +          
    +            {`
       
         指标标题
         
    @@ -94,5 +144,5 @@ export function DashboardTab() {
             
           
         
- ) + ); } diff --git a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx index 2a65455c..f5a60c16 100644 --- a/frontend/app/(main)/admin/demo/tabs/table-tab.tsx +++ b/frontend/app/(main)/admin/demo/tabs/table-tab.tsx @@ -1,92 +1,171 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -"use client" +'use client'; -import * as React from "react" -import {Code, Edit, ListFilter, Plus, Search, Trash2} from "lucide-react" -import {Button} from "@/components/ui/button" -import {Input} from "@/components/ui/input" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" -import {Badge} from "@/components/ui/badge" -import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card" +import * as React from 'react'; +import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; export function TableTab() { const mockTableData = [ - { id: "TX-1002", name: "用户信息批量同步导出", type: "数据同步", status: "success", duration: "1.2s", time: "2026-06-15 14:22" }, - { id: "TX-1003", name: "系统数据库每周自动备份归档", type: "数据库运维", status: "running", duration: "在执行中", time: "2026-06-15 15:00" }, - { id: "TX-1004", name: "远端S3过期缓存对象自动清理任务", type: "磁盘清理", status: "failed", duration: "0.4s", time: "2026-06-15 10:05" }, - ] + { + id: 'TX-1002', + name: '用户信息批量同步导出', + type: '数据同步', + status: 'success', + duration: '1.2s', + time: '2026-06-15 14:22', + }, + { + id: 'TX-1003', + name: '系统数据库每周自动备份归档', + type: '数据库运维', + status: 'running', + duration: '在执行中', + time: '2026-06-15 15:00', + }, + { + id: 'TX-1004', + name: '远端S3过期缓存对象自动清理任务', + type: '磁盘清理', + status: 'failed', + duration: '0.4s', + time: '2026-06-15 10:05', + }, + ]; return ( -
+
{/* 表格控制工具栏 */} -
-
-
- - +
+
+
+ +
-
-
{/* 数据表格本身 */} -
-
- - - 任务编号 - 执行任务名称 - 任务类型 - 状态 - 执行耗时 - 触发时间 - 操作 +
+
+ + + + 任务编号 + + + 执行任务名称 + + + 任务类型 + + + 状态 + + + 执行耗时 + + + 触发时间 + + + 操作 + {mockTableData.map((row) => ( - - {row.id} - {row.name} - {row.type} + + + {row.id} + + + {row.name} + + + {row.type} + - {row.status === "success" && ( - - + {row.status === 'success' && ( + + 成功 )} - {row.status === "running" && ( - - + {row.status === 'running' && ( + + 运行中 )} - {row.status === "failed" && ( - - + {row.status === 'failed' && ( + + 失败 )} - {row.duration} - {row.time} - -
- -
@@ -97,23 +176,62 @@ export function TableTab() { {/* 表格规范指引 */} - - - - + + + + 表格设计规范与代码模板 - -
    -
  • 表格容器必须统一使用带有虚线边框的容器,即类名:border border-dashed shadow-none rounded-lg overflow-hidden bg-background。
  • -
  • 表头背景设为 bg-muted/40。表头表项字号统一为小字粗体 text-xs font-semibold。
  • -
  • 单元格行与列之间使用虚线分割(border-dashed)。表格行悬浮交互采用温和过渡色 hover:bg-muted/10 transition-colors。
  • -
  • 编码、主键或唯一 ID 类字段一律采用等宽字体呈现:font-mono text-xs。
  • -
  • 状态标签(Badge)尽量圆角化(rounded-full py-0 px-2),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
  • + +
      +
    • + 表格容器必须统一使用带有虚线边框的容器,即类名: + + border border-dashed shadow-none rounded-lg overflow-hidden + bg-background + + 。 +
    • +
    • + 表头背景设为{' '} + + bg-muted/40 + + 。表头表项字号统一为小字粗体{' '} + + text-xs font-semibold + + 。 +
    • +
    • + 单元格行与列之间使用虚线分割( + + border-dashed + + )。表格行悬浮交互采用温和过渡色{' '} + + hover:bg-muted/10 transition-colors + + 。 +
    • +
    • + 编码、主键或唯一 ID 类字段一律采用等宽字体呈现: + + font-mono text-xs + + 。 +
    • +
    • + 状态标签(Badge)尽量圆角化( + + rounded-full py-0 px-2 + + ),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。 +
    -
    -{`
    +
    +            {`
@@ -138,5 +256,5 @@ export function TableTab() { - ) + ); } diff --git a/frontend/app/(main)/admin/files/components/file-list.tsx b/frontend/app/(main)/admin/files/components/file-list.tsx index d5f7047f..eea4f8a3 100644 --- a/frontend/app/(main)/admin/files/components/file-list.tsx +++ b/frontend/app/(main)/admin/files/components/file-list.tsx @@ -1,8 +1,8 @@ -"use client" +'use client'; -import * as React from "react" -import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" -import {AnimatePresence, motion} from "motion/react" +import * as React from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'motion/react'; import { Download, Eye, @@ -16,14 +16,21 @@ import { Trash2, Upload, X, -} from "lucide-react" -import {toast} from "sonner" +} from 'lucide-react'; +import { toast } from 'sonner'; -import {Button} from "@/components/ui/button" -import {Input} from "@/components/ui/input" -import {Badge} from "@/components/ui/badge" -import {Checkbox} from "@/components/ui/checkbox" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; import { AlertDialog, AlertDialogAction, @@ -33,207 +40,236 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" -import {FileImagePreview} from "@/components/common/file-image-preview" -import services, {formatFileSize} from "@/lib/services" -import type {Upload as UploadRecord} from "@/lib/services/upload/types" +} from '@/components/ui/alert-dialog'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { FileImagePreview } from '@/components/common/file-image-preview'; +import services, { formatFileSize } from '@/lib/services'; +import type { Upload as UploadRecord } from '@/lib/services/upload/types'; /* ─── 工具函数 ─────────────────────────────────────────── */ -function getFileIcon(mimeType: string, className = "size-8") { - if (mimeType.startsWith("image/")) return - if (mimeType.startsWith("video/")) return - if (mimeType.startsWith("audio/")) return - if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip")) - return - return +function getFileIcon(mimeType: string, className = 'size-8') { + if (mimeType.startsWith('image/')) + return ; + if (mimeType.startsWith('video/')) + return ; + if (mimeType.startsWith('audio/')) + return ; + if ( + mimeType.includes('zip') || + mimeType.includes('tar') || + mimeType.includes('gzip') + ) + return ; + return ; } function formatDate(dateStr: string) { - return new Date(dateStr).toLocaleString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) + return new Date(dateStr).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); } export function FileList() { - const queryClient = useQueryClient() - const [keyword, setKeyword] = React.useState("") - const [debouncedKeyword, setDebouncedKeyword] = React.useState("") - const [selectedIds, setSelectedIds] = React.useState>(new Set()) - const [deleteTarget, setDeleteTarget] = React.useState(null) - const [detailTarget, setDetailTarget] = React.useState(null) - const [page, setPage] = React.useState(1) - const pageSize = 15 // 表格视图下 15 条更紧凑 + const queryClient = useQueryClient(); + const [keyword, setKeyword] = React.useState(''); + const [debouncedKeyword, setDebouncedKeyword] = React.useState(''); + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const [deleteTarget, setDeleteTarget] = React.useState( + null, + ); + const [detailTarget, setDetailTarget] = React.useState( + null, + ); + const [page, setPage] = React.useState(1); + const pageSize = 15; // 表格视图下 15 条更紧凑 // 搜索防抖 React.useEffect(() => { const timer = setTimeout(() => { - setDebouncedKeyword(keyword) - setPage(1) - }, 400) - return () => clearTimeout(timer) - }, [keyword]) + setDebouncedKeyword(keyword); + setPage(1); + }, 400); + return () => clearTimeout(timer); + }, [keyword]); // 文件列表查询 const listQuery = useQuery({ - queryKey: ["files", "all", page, pageSize, debouncedKeyword], - queryFn: () => services.adminUpload.listUploads(page, pageSize, debouncedKeyword || undefined), - }) + queryKey: ['files', 'all', page, pageSize, debouncedKeyword], + queryFn: () => + services.adminUpload.listUploads( + page, + pageSize, + debouncedKeyword || undefined, + ), + }); const storageDriverQuery = useQuery({ - queryKey: ["admin", "storage-config", "driver"], + queryKey: ['admin', 'storage-config', 'driver'], queryFn: async () => { - const record = await services.adminSystemConfig.getSystemConfig("storage_config") - const cfg = JSON.parse(record.value) as {driver?: string} - return cfg.driver ?? "local" + const record = + await services.adminSystemConfig.getSystemConfig('storage_config'); + const cfg = JSON.parse(record.value) as { driver?: string }; + return cfg.driver ?? 'local'; }, - }) + }); - const files = listQuery.data?.items ?? [] - const total = listQuery.data?.total ?? 0 - const totalPages = Math.ceil(total / pageSize) + const files = listQuery.data?.items ?? []; + const total = listQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / pageSize); - const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id)) - const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected + const isAllSelected = + files.length > 0 && files.every((f) => selectedIds.has(f.id)); + const isSomeSelected = + files.length > 0 && + files.some((f) => selectedIds.has(f.id)) && + !isAllSelected; const handleSelectAll = () => { if (isAllSelected) { setSelectedIds((prev) => { - const next = new Set(prev) - files.forEach((f) => next.delete(f.id)) - return next - }) + const next = new Set(prev); + files.forEach((f) => next.delete(f.id)); + return next; + }); } else { setSelectedIds((prev) => { - const next = new Set(prev) - files.forEach((f) => next.add(f.id)) - return next - }) + const next = new Set(prev); + files.forEach((f) => next.add(f.id)); + return next; + }); } - } + }; // 删除单文件 const deleteMutation = useMutation({ mutationFn: (id: string) => services.adminUpload.deleteFile(id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["files", "my"] }) - void queryClient.invalidateQueries({ queryKey: ["files", "stats"] }) - toast.success("文件已删除") - setDeleteTarget(null) + void queryClient.invalidateQueries({ queryKey: ['files', 'my'] }); + void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] }); + toast.success('文件已删除'); + setDeleteTarget(null); }, - onError: (err: Error) => toast.error(err.message || "删除失败"), - }) + onError: (err: Error) => toast.error(err.message || '删除失败'), + }); // 批量 ZIP 下载 const batchDownloadMutation = useMutation({ mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids), onSuccess: (blob) => { - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = "batch_download.zip" - a.click() - URL.revokeObjectURL(url) - toast.success("批量下载已开始") + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'batch_download.zip'; + a.click(); + URL.revokeObjectURL(url); + toast.success('批量下载已开始'); }, - onError: () => toast.error("批量下载失败"), - }) + onError: () => toast.error('批量下载失败'), + }); const toggleSelect = (id: string) => { setSelectedIds((prev) => { - const next = new Set(prev) + const next = new Set(prev); if (next.has(id)) { - next.delete(id) + next.delete(id); } else { - next.add(id) + next.add(id); } - return next - }) - } + return next; + }); + }; - const clearSelection = () => setSelectedIds(new Set()) + const clearSelection = () => setSelectedIds(new Set()); - const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id))) + const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id))); const handleDownload = (file: UploadRecord) => { - const url = services.adminUpload.getDownloadUrl(file.id) - const a = document.createElement("a") - a.href = url - a.download = file.file_name - a.click() - } + const url = services.adminUpload.getDownloadUrl(file.id); + const a = document.createElement('a'); + a.href = url; + a.download = file.file_name; + a.click(); + }; return ( -
+
{/* 搜索栏 */} -
-
- +
+
+ setKeyword(e.target.value)} /> {keyword && ( )}
{files.length > 0 && ( )} {/* 打包下载等批量操作 */} -
+
{selectedIds.size > 0 && ( - + 已选 {selectedIds.size} 个 )} @@ -241,131 +277,152 @@ export function FileList() {
{total > 0 && ( - 共 {total} 个文件 + + 共 {total} 个文件 + )}
{/* 文件列表 Table */} {listQuery.isPending ? ( -
- +
+
) : files.length === 0 ? ( -
- -

- {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"} +

+ +

+ {debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'}

) : ( -
+
- - + + - 预览 - ID - 文件名 - 路径 - 业务类别 - MIME类型 - 大小 - 上传时间 - 操作 + 预览 + ID + 文件名 + + 路径 + + 业务类别 + MIME类型 + 大小 + 上传时间 + + 操作 + {files.map((file) => { - const isSelected = selectedIds.has(file.id) + const isSelected = selectedIds.has(file.id); return ( - + toggleSelect(file.id)} /> - -
- {file.mime_type.startsWith("image/") ? ( + +
+ {file.mime_type.startsWith('image/') ? ( ) : ( - getFileIcon(file.mime_type, "size-4.5") + getFileIcon(file.mime_type, 'size-4.5') )}
- + {file.id} - + {file.file_name} - + {file.file_path} - - + + {file.type} - + {file.mime_type} - + {formatFileSize(file.file_size)} - + {formatDate(file.created_at)} - -
+ +
- ) + ); })}
@@ -374,23 +431,23 @@ export function FileList() { {/* 分页 */} {totalPages > 1 && ( -
+
- + {page} / {totalPages}
- -
- - + +
+ + - - - + + + - - - + + + - + (trendMetric === "size" ? formatFileSize(v) : v.toString())} + style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} + tickFormatter={(v) => + trendMetric === 'size' ? formatFileSize(v) : v.toString() + } /> { - const label = trendMetric === "count" ? "新增文件数" : "新增大小" + const label = + trendMetric === 'count' ? '新增文件数' : '新增大小'; const formattedValue = - trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个` + trendMetric === 'size' + ? formatFileSize(Number(value)) + : `${value} 个`; return ( <> -
+
- {label} + + {label} +
- + {formattedValue} - ) + ); }} /> } /> @@ -319,30 +414,37 @@ export function FileStats() { {/* 两个饼图分布 */} -
+
{/* 饼图 1: 格式数量分布 */} - + - 文件格式数量分布 - 各种格式类型的文件个数占比 + + 文件格式数量分布 + + + 各种格式类型的文件个数占比 + - + {categoryCountData.length === 0 ? ( -
+
暂无分类数据
) : ( -
- +
+ {categoryCountData.map((entry, index) => ( @@ -354,66 +456,88 @@ export function FileStats() { { - const configObj = statsChartConfig[name as keyof typeof statsChartConfig] - const label = configObj?.label || name - const color = configObj?.color || "hsl(var(--muted))" + const configObj = + statsChartConfig[ + name as keyof typeof statsChartConfig + ]; + const label = configObj?.label || name; + const color = + configObj?.color || 'hsl(var(--muted))'; return ( <> -
- - {label} +
+ + + {label} +
- + {value} 个 - ) + ); }} /> } /> } - className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4" + content={} + className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4' />
)} -
- -

- 文件类型中数量最多的是{" "} - 「{maxStats.maxCountName}」,累计上传了{" "} - {maxStats.maxCount} 个文件。 +

+ +

+ 文件类型中数量最多的是{' '} + + 「{maxStats.maxCountName}」 + + ,累计上传了{' '} + + {maxStats.maxCount} + {' '} + 个文件。

{/* 饼图 2: 格式容量分布 */} - + - 文件占用容量分布 - 各种格式类型的文件大小占比 + + 文件占用容量分布 + + + 各种格式类型的文件大小占比 + - + {categorySizeData.length === 0 ? ( -
+
暂无容量数据
) : ( -
- +
+ {categorySizeData.map((entry, index) => ( @@ -425,38 +549,52 @@ export function FileStats() { { - const configObj = statsChartConfig[name as keyof typeof statsChartConfig] - const label = configObj?.label || name - const color = configObj?.color || "hsl(var(--muted))" + const configObj = + statsChartConfig[ + name as keyof typeof statsChartConfig + ]; + const label = configObj?.label || name; + const color = + configObj?.color || 'hsl(var(--muted))'; return ( <> -
- - {label} +
+ + + {label} +
- + {formatFileSize(Number(value))} - ) + ); }} /> } /> } - className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4" + content={} + className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4' />
)} -
- -

- 文件类型中占用存储最大的是{" "} - 「{maxStats.maxSizeName}」,共消耗了{" "} - {formatFileSize(maxStats.maxSize)}{" "} +

+ +

+ 文件类型中占用存储最大的是{' '} + + 「{maxStats.maxSizeName}」 + + ,共消耗了{' '} + + {formatFileSize(maxStats.maxSize)} + {' '} 存储容量。

@@ -464,5 +602,5 @@ export function FileStats() {
- ) + ); } diff --git a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx index 29642f35..d6a1b741 100644 --- a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx +++ b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx @@ -1,104 +1,130 @@ -"use client" +'use client'; -import * as React from "react" -import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" -import {Database, Loader2, Play, Save} from "lucide-react" -import {toast} from "sonner" +import * as React from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Database, Loader2, Play, Save } from 'lucide-react'; +import { toast } from 'sonner'; -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" -import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card" -import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field" -import {Input} from "@/components/ui/input" -import {Progress} from "@/components/ui/progress" -import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" -import {Switch} from "@/components/ui/switch" -import services from "@/lib/services" +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import services from '@/lib/services'; import type { ObjectStorageConfig, StorageConfig, StorageDriver, TaskExecution, - TaskExecutionStatus -} from "@/lib/services/admin/types" + TaskExecutionStatus, +} from '@/lib/services/admin/types'; -const storageConfigKey = "storage_config" -const storageMigrationTaskType = "storage_migration" +const storageConfigKey = 'storage_config'; +const storageMigrationTaskType = 'storage_migration'; const driverLabels: Record = { - local: "本地文件系统", - s3: "AWS S3", - r2: "Cloudflare R2", - minio: "MinIO", - oss: "阿里云 OSS", - webdav: "WebDAV", -} + local: '本地文件系统', + s3: 'AWS S3', + r2: 'Cloudflare R2', + minio: 'MinIO', + oss: '阿里云 OSS', + webdav: 'WebDAV', +}; const emptyObjectConfig: ObjectStorageConfig = { - endpoint: "", - region: "", - bucket: "", - access_key_id: "", - secret_access_key: "", + endpoint: '', + region: '', + bucket: '', + access_key_id: '', + secret_access_key: '', path_style: false, - key_prefix: "", - cdn_url: "", -} + key_prefix: '', + cdn_url: '', +}; function normalizeConfig(config: StorageConfig): StorageConfig { - const s3 = {...emptyObjectConfig, ...config.s3} - const r2 = {...emptyObjectConfig, ...config.r2} - const minio = {...emptyObjectConfig, ...config.minio} - const oss = {...emptyObjectConfig, ...config.oss} - const webdav = {...config.webdav} + const s3 = { ...emptyObjectConfig, ...config.s3 }; + const r2 = { ...emptyObjectConfig, ...config.r2 }; + const minio = { ...emptyObjectConfig, ...config.minio }; + const oss = { ...emptyObjectConfig, ...config.oss }; + const webdav = { ...config.webdav }; return { ...config, - local: {root: config.local?.root || "."}, - s3: {...s3, region: s3.region || "us-east-1"}, - r2: {...r2, region: r2.region || "auto"}, - minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true}, + local: { root: config.local?.root || '.' }, + s3: { ...s3, region: s3.region || 'us-east-1' }, + r2: { ...r2, region: r2.region || 'auto' }, + minio: { + ...minio, + region: minio.region || 'us-east-1', + path_style: config.minio?.path_style ?? true, + }, oss, webdav: { - endpoint: webdav.endpoint || "", - username: webdav.username || "", - password: webdav.password || "", - base_path: webdav.base_path || "", + endpoint: webdav.endpoint || '', + username: webdav.username || '', + password: webdav.password || '', + base_path: webdav.base_path || '', }, - } + }; } type StorageMigrationView = { - state: "idle" | TaskExecutionStatus - id?: string - task_id?: string - source_driver?: StorageDriver - target_driver?: StorageDriver - error?: string -} + state: 'idle' | TaskExecutionStatus; + id?: string; + task_id?: string; + source_driver?: StorageDriver; + target_driver?: StorageDriver; + error?: string; +}; type StorageMigrationPayload = { - target: StorageConfig -} + target: StorageConfig; +}; function parseMigrationPayload(payload: string): StorageConfig | undefined { try { - const parsed = JSON.parse(payload) as Partial - return parsed.target ? normalizeConfig(parsed.target) : undefined + const parsed = JSON.parse(payload) as Partial; + return parsed.target ? normalizeConfig(parsed.target) : undefined; } catch { - return undefined + return undefined; } } function driverLabel(driver?: StorageDriver): string { - return driver ? driverLabels[driver] : "未知" + return driver ? driverLabels[driver] : '未知'; } -function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView { - const execution = executions[0] - if (!execution || execution.status === "succeeded") { - return {state: "idle"} +function latestMigration( + executions: TaskExecution[], + current?: StorageConfig, +): StorageMigrationView { + const execution = executions[0]; + if (!execution || execution.status === 'succeeded') { + return { state: 'idle' }; } - const target = parseMigrationPayload(execution.payload) + const target = parseMigrationPayload(execution.payload); return { state: execution.status, id: execution.id, @@ -106,227 +132,339 @@ function latestMigration(executions: TaskExecution[], current?: StorageConfig): source_driver: current?.driver, target_driver: target?.driver, error: execution.error_message, - } + }; } export function StorageConfigTab() { - const queryClient = useQueryClient() - const [config, setConfig] = React.useState(null) + const queryClient = useQueryClient(); + const [config, setConfig] = React.useState(null); const query = useQuery({ - queryKey: ["admin", "storage-config"], + queryKey: ['admin', 'storage-config'], queryFn: async () => { const [configRecord, executions] = await Promise.all([ services.adminSystemConfig.getSystemConfig(storageConfigKey), - services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}), - ]) - const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig) - const migration = latestMigration(executions.items, current) + services.adminTask.listTaskExecutions({ + task_type: storageMigrationTaskType, + page: 1, + page_size: 1, + }), + ]); + const current = normalizeConfig( + JSON.parse(configRecord.value) as StorageConfig, + ); + const migration = latestMigration(executions.items, current); return { config: current, migration, - } + }; }, refetchInterval: (result) => { - const state = result.state.data?.migration.state - return state === "pending" || state === "running" ? 2000 : false + const state = result.state.data?.migration.state; + return state === 'pending' || state === 'running' ? 2000 : false; }, - }) + }); React.useEffect(() => { if (query.data?.config) { setConfig((current) => { - const normalized = normalizeConfig(query.data.config) + const normalized = normalizeConfig(query.data.config); if (current) { return { ...normalized, driver: current.driver, - } + }; } - return normalized - }) + return normalized; + }); } - }, [query.data?.config]) + }, [query.data?.config]); const saveMutation = useMutation({ mutationFn: async (value: StorageConfig) => { await services.adminSystemConfig.updateSystemConfig(storageConfigKey, { value: JSON.stringify(value), - }) + }); }, onSuccess: () => { - toast.success("存储配置已保存") - void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) + toast.success('存储配置已保存'); + void queryClient.invalidateQueries({ + queryKey: ['admin', 'storage-config'], + }); }, onError: (error: Error) => { - toast.error(error.message || "保存存储配置失败") - void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) + toast.error(error.message || '保存存储配置失败'); + void queryClient.invalidateQueries({ + queryKey: ['admin', 'storage-config'], + }); }, - }) + }); const migrateMutation = useMutation({ mutationFn: async (value: StorageConfig) => { - const payload: StorageMigrationPayload = {target: value} + const payload: StorageMigrationPayload = { target: value }; return services.adminTask.dispatchTask({ task_type: storageMigrationTaskType, payload: JSON.stringify(payload), - }) + }); }, onSuccess: () => { - toast.success("存储迁移任务已下发") - void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) + toast.success('存储迁移任务已下发'); + void queryClient.invalidateQueries({ + queryKey: ['admin', 'storage-config'], + }); }, onError: (error: Error) => { - toast.error(error.message || "下发存储迁移任务失败") - void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) + toast.error(error.message || '下发存储迁移任务失败'); + void queryClient.invalidateQueries({ + queryKey: ['admin', 'storage-config'], + }); }, - }) + }); const runMutation = useMutation({ - mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID), + mutationFn: (executionID: string) => + services.adminTask.retryTaskExecution(executionID), onSuccess: () => { - toast.success("存储迁移任务已重新下发") - void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) + toast.success('存储迁移任务已重新下发'); + void queryClient.invalidateQueries({ + queryKey: ['admin', 'storage-config'], + }); }, - onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"), - }) + onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'), + }); if (query.isPending || !config) { - return
+ return ( +
+ +
+ ); } - const migration = query.data?.migration - const isReadOnly = migration ? migration.state !== "idle" : false + const migration = query.data?.migration; + const isReadOnly = migration ? migration.state !== 'idle' : false; const isFormDisabled = - (migration ? (migration.state === "pending" || migration.state === "running") : false) || + (migration + ? migration.state === 'pending' || migration.state === 'running' + : false) || saveMutation.isPending || - migrateMutation.isPending + migrateMutation.isPending; - const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial) => { - setConfig((current) => current ? { - ...current, - [driver]: {...current[driver], ...patch}, - } : current) - } + const updateObject = ( + driver: 's3' | 'r2' | 'minio' | 'oss', + patch: Partial, + ) => { + setConfig((current) => + current + ? { + ...current, + [driver]: { ...current[driver], ...patch }, + } + : current, + ); + }; return ( -
+
{isReadOnly && migration && ( - + 存储维护模式 - + {migration.state} - {driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。 + {driverLabel(migration.source_driver)} →{' '} + {driverLabel(migration.target_driver)} + 。迁移期间文件只允许读取,禁止上传、删除和清理。 - - -

- 迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"} + + +

+ 迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}

- {migration.error &&

{migration.error}

} + {migration.error && ( +

{migration.error}

+ )}
)} - - -
-
- + + +
+
+
- 文件存储 - + + 文件存储 + + 默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
- - -
- - 存储类型 + + +
+ + + 存储类型 + - {config.driver === "local" && ( -
+ {config.driver === 'local' && ( +
setConfig({...config, local: {root}})} + placeholder='.' + onChange={(root) => + setConfig({ ...config, local: { root } }) + } />
)} - {(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && ( + {(config.driver === 's3' || + config.driver === 'r2' || + config.driver === 'minio' || + config.driver === 'oss') && ( updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)} + driver={config.driver as 's3' | 'r2' | 'minio' | 'oss'} + value={config[config.driver as 's3' | 'r2' | 'minio' | 'oss']} + onChange={(patch) => + updateObject( + config.driver as 's3' | 'r2' | 'minio' | 'oss', + patch, + ) + } /> )} - {config.driver === "webdav" && ( + {config.driver === 'webdav' && ( <> -
- setConfig({...config, webdav: {...config.webdav, endpoint}})} /> +
+ + setConfig({ + ...config, + webdav: { ...config.webdav, endpoint }, + }) + } + />
- setConfig({...config, webdav: {...config.webdav, username}})} /> - setConfig({...config, webdav: {...config.webdav, password}})} /> -
- setConfig({...config, webdav: {...config.webdav, base_path}})} /> + + setConfig({ + ...config, + webdav: { ...config.webdav, username }, + }) + } + /> + + setConfig({ + ...config, + webdav: { ...config.webdav, password }, + }) + } + /> +
+ + setConfig({ + ...config, + webdav: { ...config.webdav, base_path }, + }) + } + />
)}
- + {config.driver !== query.data?.config.driver && ( - - ⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。 + + ⚠️ + 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。 )} -
+
{config.driver !== query.data?.config.driver && ( @@ -334,7 +472,11 @@ export function StorageConfigTab() { disabled={isFormDisabled} onClick={() => migrateMutation.mutate(config)} > - {migrateMutation.isPending ? : } + {migrateMutation.isPending ? ( + + ) : ( + + )} 开始迁移 )} @@ -342,7 +484,7 @@ export function StorageConfigTab() {
- ) + ); } function ObjectFields({ @@ -350,41 +492,87 @@ function ObjectFields({ value, onChange, }: { - driver: "s3" | "r2" | "minio" | "oss" - value: ObjectStorageConfig - onChange: (patch: Partial) => void + driver: 's3' | 'r2' | 'minio' | 'oss'; + value: ObjectStorageConfig; + onChange: (patch: Partial) => void; }) { return ( <> - {driver === "r2" && ( -
- onChange({account_id})} /> + {driver === 'r2' && ( +
+ onChange({ account_id })} + />
)} - {driver !== "s3" && ( -
- onChange({endpoint})} /> + {driver !== 's3' && ( +
+ onChange({ endpoint })} + />
)} - onChange({region})} /> - onChange({bucket})} /> - onChange({access_key_id})} /> - onChange({secret_access_key})} /> - onChange({key_prefix})} /> - onChange({cdn_url})} /> - {(driver === "s3" || driver === "minio") && ( -
- -
- Path Style - MinIO 等自托管 S3 通常需要开启。 + onChange({ region })} + /> + onChange({ bucket })} + /> + onChange({ access_key_id })} + /> + onChange({ secret_access_key })} + /> + onChange({ key_prefix })} + /> + onChange({ cdn_url })} + /> + {(driver === 's3' || driver === 'minio') && ( +
+ +
+ + Path Style + + + MinIO 等自托管 S3 通常需要开启。 +
- onChange({path_style})} /> + onChange({ path_style })} + />
)} - ) + ); } function TextField({ @@ -392,24 +580,24 @@ function TextField({ value, onChange, placeholder, - type = "text", + type = 'text', }: { - label: string - value: string - onChange: (value: string) => void - placeholder?: string - type?: React.HTMLInputTypeAttribute + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: React.HTMLInputTypeAttribute; }) { return ( - - {label} + + {label} onChange(event.target.value)} - className="border-dashed bg-card text-xs" + className='border-dashed bg-card text-xs' /> - ) + ); } diff --git a/frontend/app/(main)/admin/files/page.tsx b/frontend/app/(main)/admin/files/page.tsx index 463518ba..b2c1942a 100644 --- a/frontend/app/(main)/admin/files/page.tsx +++ b/frontend/app/(main)/admin/files/page.tsx @@ -1,59 +1,65 @@ -"use client" +'use client'; -import * as React from "react" -import {motion} from "motion/react" -import {FolderOpen} from "lucide-react" +import * as React from 'react'; +import { motion } from 'motion/react'; +import { FolderOpen } from 'lucide-react'; -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" -import {FileStats} from "./components/file-stats" -import {FileList} from "./components/file-list" -import {StorageConfigTab} from "./components/storage-config-tab" +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { FileStats } from './components/file-stats'; +import { FileList } from './components/file-list'; +import { StorageConfigTab } from './components/storage-config-tab'; export default function FilesPage() { - const [activeTab, setActiveTab] = React.useState("stats") + const [activeTab, setActiveTab] = React.useState('stats'); return ( {/* 顶部标题区 */} -
- +
+
-

存储管理

+

存储管理

- - - + + + 文件存储信息 - + 文件列表 - + 存储配置 {/* ──────── TAB 1: 统计看板 ──────── */} - - {activeTab === "stats" ? : null} + + {activeTab === 'stats' ? : null} {/* ──────── TAB 2: 文件列表 ──────── */} - - {activeTab === "list" ? : null} + + {activeTab === 'list' ? : null} - - {activeTab === "storage" ? : null} + + {activeTab === 'storage' ? : null} - ) + ); } diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx index a7399130..229543cf 100644 --- a/frontend/app/(main)/admin/layout.tsx +++ b/frontend/app/(main)/admin/layout.tsx @@ -1,18 +1,16 @@ -"use client" +'use client'; -import {AdminUsersProvider} from "@/contexts/admin-users-context" -import {RequireAdminAuth} from "@/components/auth/require-auth" +import { AdminUsersProvider } from '@/contexts/admin-users-context'; +import { RequireAdminAuth } from '@/components/auth/require-auth'; export default function AdminLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - - {children} - + {children} - ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/(main)/admin/logs/components/access-analytics.tsx b/frontend/app/(main)/admin/logs/components/access-analytics.tsx index d5c44d5c..6cafdf3e 100644 --- a/frontend/app/(main)/admin/logs/components/access-analytics.tsx +++ b/frontend/app/(main)/admin/logs/components/access-analytics.tsx @@ -14,209 +14,282 @@ See the License for the specific language governing permissions and limitations under the License. */ -"use client" +'use client'; -import {useCallback, useEffect, useState} from "react" -import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react" -import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts" +import { useCallback, useEffect, useState } from 'react'; +import { + BarChart3, + Globe, + RefreshCw, + TrendingUp, + Users, + XCircle, +} from 'lucide-react'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import services from "@/lib/services" -import {ErrorInline} from "@/components/layout/error" -import {LoadingStateWithBorder} from "@/components/layout/loading" -import {EmptyStateWithBorder} from "@/components/layout/empty" -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart" -import {Spinner} from "@/components/ui/spinner" +import services from '@/lib/services'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { Spinner } from '@/components/ui/spinner'; interface TrendData { - date: string - count: number + date: string; + count: number; } interface BrowserData { - browser: string - count: number + browser: string; + count: number; } interface TopUserData { - user_id: string - username: string - nickname: string - count: number + user_id: string; + username: string; + nickname: string; + count: number; } const chartConfig = { count: { - label: "请求量", - color: "hsl(var(--primary))", + label: '请求量', + color: 'hsl(var(--primary))', }, -} satisfies ChartConfig +} satisfies ChartConfig; // Format YYYY-MM-DD to MM/DD function formatDateLabel(dateStr: string) { - if (!dateStr || dateStr.length < 10) return dateStr - const parts = dateStr.split("-") + if (!dateStr || dateStr.length < 10) return dateStr; + const parts = dateStr.split('-'); if (parts.length === 3) { - return `${parts[1]}/${parts[2]}` + return `${parts[1]}/${parts[2]}`; } - return dateStr + return dateStr; } export function AccessAnalytics() { - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [clickhouseDisabled, setClickhouseDisabled] = useState(false) + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [clickhouseDisabled, setClickhouseDisabled] = useState(false); - const [trend, setTrend] = useState([]) - const [browsers, setBrowsers] = useState([]) - const [topUsers, setTopUsers] = useState([]) + const [trend, setTrend] = useState([]); + const [browsers, setBrowsers] = useState([]); + const [topUsers, setTopUsers] = useState([]); const fetchAnalytics = useCallback(async () => { try { - setLoading(true) - setError(null) - const data = await services.adminLog.getLogsAnalytics() + setLoading(true); + setError(null); + const data = await services.adminLog.getLogsAnalytics(); // Formats the date labels for the X-axis representation - const formattedTrend = (data.trend || []).map(item => ({ + const formattedTrend = (data.trend || []).map((item) => ({ ...item, - formattedDate: formatDateLabel(item.date) - })) + formattedDate: formatDateLabel(item.date), + })); - setTrend(formattedTrend) - setBrowsers(data.browsers || []) - setTopUsers(data.top_users || []) - setClickhouseDisabled(false) + setTrend(formattedTrend); + setBrowsers(data.browsers || []); + setTopUsers(data.top_users || []); + setClickhouseDisabled(false); } catch (err) { - const errorInstance = err instanceof Error ? err : new Error("获取数据统计失败") - const errMsg = errorInstance.message || "" - if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) { - setClickhouseDisabled(true) + const errorInstance = + err instanceof Error ? err : new Error('获取数据统计失败'); + const errMsg = errorInstance.message || ''; + if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) { + setClickhouseDisabled(true); } else { - setError(errorInstance) + setError(errorInstance); } } finally { - setLoading(false) + setLoading(false); } - }, []) + }, []); useEffect(() => { - fetchAnalytics() - }, [fetchAnalytics]) + fetchAnalytics(); + }, [fetchAnalytics]); - const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0) - const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0) + const totalBrowserRequests = browsers.reduce( + (sum, item) => sum + item.count, + 0, + ); + const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0); if (clickhouseDisabled) { return ( -
- -

ClickHouse 未启用

-

- 当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。 +

+ +

ClickHouse 未启用

+

+ 当前系统配置未启用 ClickHouse + 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` + 配置文件中配置并启用 ClickHouse。

- ) + ); } if (error) { - return + return ; } if (loading) { - return + return ( + + ); } return ( -
+
{/* Overview Cards */} -
- - - 近 7 天总请求数 - +
+ + + + 近 7 天总请求数 + + -
{totalTrendRequests.toLocaleString()}
-

系统记录的所有成功认证的访问总频次

+
+ {totalTrendRequests.toLocaleString()} +
+

+ 系统记录的所有成功认证的访问总频次 +

- - - 活跃终端分类数 - + + + + 活跃终端分类数 + + -
{browsers.length}
-

在一周内发起请求的浏览器代理大类统计

+
+ {browsers.length} +
+

+ 在一周内发起请求的浏览器代理大类统计 +

- - - 活跃独立用户数 - + + + + 活跃独立用户数 + + -
{topUsers.length}
-

一周内累计发起高频请求的注册账户总量

+
+ {topUsers.length} +
+

+ 一周内累计发起高频请求的注册账户总量 +

{/* Access Trend Chart */} - - + +
- 一周访问量趋势 - + + 一周访问量趋势 + + 展现系统最近 7 天内的每日 API 请求曲线
-
- + {trend.length === 0 ? ( - + ) : ( -
- +
+ - - - + + + - + } /> @@ -226,46 +299,56 @@ export function AccessAnalytics() { {/* Two Columns for Ranking Statistics */} -
+
{/* Browser Rankings */} - + - - + + 使用的浏览器排行 - + 基于请求头 User-Agent 智能分类的一周占比统计 - + {browsers.length === 0 ? ( - + ) : ( -
+
{browsers.map((item, index) => { - const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0 + const percent = + totalBrowserRequests > 0 + ? (item.count / totalBrowserRequests) * 100 + : 0; return ( -
-
- - +
+
+ + {index + 1} - {item.browser} + {item.browser} - - {item.count.toLocaleString()} 次 ({percent.toFixed(1)}%) + + {item.count.toLocaleString()} 次 ({percent.toFixed(1)} + %)
-
+
- ) + ); })}
)} @@ -273,39 +356,50 @@ export function AccessAnalytics() { {/* Top Users */} - + - - + + 最活跃的用户排行 (Top 10) - + 统计最近一周发起接口访问请求数量最多的账户 - + {topUsers.length === 0 ? ( - + ) : ( -
+
{topUsers.map((user) => ( -
-
-
- {(user.username || "U").slice(0, 1).toUpperCase()} +
+
+
+ {(user.username || 'U').slice(0, 1).toUpperCase()}
-
-
- {user.username || "未知"} +
+
+ {user.username || '未知'}
-
- {user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id} +
+ {user.nickname ? `(${user.nickname})` : '(无昵称)'} | + ID: {user.user_id}
-
-
{user.count.toLocaleString()}
-
次请求
+
+
+ {user.count.toLocaleString()} +
+
+ 次请求 +
))} @@ -315,5 +409,5 @@ export function AccessAnalytics() {
- ) + ); } diff --git a/frontend/app/(main)/admin/logs/components/access-logs.tsx b/frontend/app/(main)/admin/logs/components/access-logs.tsx index 99d0fb18..dcba3fc4 100644 --- a/frontend/app/(main)/admin/logs/components/access-logs.tsx +++ b/frontend/app/(main)/admin/logs/components/access-logs.tsx @@ -14,441 +14,540 @@ See the License for the specific language governing permissions and limitations under the License. */ -"use client" +'use client'; -import {useMemo, useState} from "react" -import {useQuery} from "@tanstack/react-query" -import {toast} from "sonner" -import {format} from "date-fns" -import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react" +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { format } from 'date-fns'; +import { + Activity, + ChevronLeft, + ChevronRight, + Copy, + Eye, + RotateCcw, + Search, + XCircle, +} from 'lucide-react'; -import services from "@/lib/services" -import {ErrorInline} from "@/components/layout/error" -import {LoadingStateWithBorder} from "@/components/layout/loading" -import {EmptyStateWithBorder} from "@/components/layout/empty" -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" -import {Input} from "@/components/ui/input" -import {Label} from "@/components/ui/label" -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" +import services from '@/lib/services'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; interface AccessLog { - id: string - user_id: string - username: string - nickname: string - path: string - method: string - ip: string - user_agent: string - headers: string - status: number - latency: number - created_at: string + id: string; + user_id: string; + username: string; + nickname: string; + path: string; + method: string; + ip: string; + user_agent: string; + headers: string; + status: number; + latency: number; + created_at: string; } -const PAGE_SIZE = 15 +const PAGE_SIZE = 15; function formatDateTime(value?: string | null) { - if (!value) return "-" - const date = new Date(value) - if (Number.isNaN(date.getTime())) return value - return format(date, "yyyy-MM-dd HH:mm:ss") + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return format(date, 'yyyy-MM-dd HH:mm:ss'); } function formatLatency(ms: number) { - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(2)}s` + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; } function statusVariant(status: number) { - if (status >= 500) return "destructive" - if (status >= 400) return "destructive" - if (status >= 300) return "outline" - return "secondary" + if (status >= 500) return 'destructive'; + if (status >= 400) return 'destructive'; + if (status >= 300) return 'outline'; + return 'secondary'; } function isClickhouseDisabledError(error: Error) { - const errMsg = error.message || "" - return errMsg.includes("ClickHouse") || errMsg.includes("未启用") + const errMsg = error.message || ''; + return errMsg.includes('ClickHouse') || errMsg.includes('未启用'); } export function AccessLogs() { - const [page, setPage] = useState(1) + const [page, setPage] = useState(1); - // 搜索过滤条件 - const [usernameFilter, setUsernameFilter] = useState("") - const [pathFilter, setPathFilter] = useState("") - const [startTimeFilter, setStartTimeFilter] = useState("") - const [endTimeFilter, setEndTimeFilter] = useState("") + // 搜索过滤条件 + const [usernameFilter, setUsernameFilter] = useState(''); + const [pathFilter, setPathFilter] = useState(''); + const [startTimeFilter, setStartTimeFilter] = useState(''); + const [endTimeFilter, setEndTimeFilter] = useState(''); - // 实际提交的搜索条件 - const [searchParams, setSearchParams] = useState({ - username: "", - path: "", - start_time: "", - end_time: "", - }) + // 实际提交的搜索条件 + const [searchParams, setSearchParams] = useState({ + username: '', + path: '', + start_time: '', + end_time: '', + }); - const [selectedLog, setSelectedLog] = useState(null) - const [detailOpen, setDetailOpen] = useState(false) + const [selectedLog, setSelectedLog] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); - const logsQuery = useQuery({ - queryKey: ["admin", "access-logs", page, searchParams], - queryFn: async () => { - let startISO = "" - if (searchParams.start_time) { - startISO = new Date(searchParams.start_time).toISOString() - } - let endISO = "" - if (searchParams.end_time) { - endISO = new Date(searchParams.end_time).toISOString() - } + const logsQuery = useQuery({ + queryKey: ['admin', 'access-logs', page, searchParams], + queryFn: async () => { + let startISO = ''; + if (searchParams.start_time) { + startISO = new Date(searchParams.start_time).toISOString(); + } + let endISO = ''; + if (searchParams.end_time) { + endISO = new Date(searchParams.end_time).toISOString(); + } - return services.adminLog.getAccessLogs({ - page, - page_size: PAGE_SIZE, - username: searchParams.username || undefined, - path: searchParams.path || undefined, - start_time: startISO || undefined, - end_time: endISO || undefined, - }) - }, - retry: false, - }) + return services.adminLog.getAccessLogs({ + page, + page_size: PAGE_SIZE, + username: searchParams.username || undefined, + path: searchParams.path || undefined, + start_time: startISO || undefined, + end_time: endISO || undefined, + }); + }, + retry: false, + }); - const logs = logsQuery.data?.list ?? [] - const total = logsQuery.data?.total ?? 0 - const loading = logsQuery.isPending || logsQuery.isFetching - const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error) - const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null + const logs = logsQuery.data?.list ?? []; + const total = logsQuery.data?.total ?? 0; + const loading = logsQuery.isPending || logsQuery.isFetching; + const clickhouseDisabled = + logsQuery.isError && isClickhouseDisabledError(logsQuery.error); + const error = + logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null; - const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]) + const totalPages = useMemo( + () => Math.max(1, Math.ceil(total / PAGE_SIZE)), + [total], + ); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault() - setPage(1) - setSearchParams({ - username: usernameFilter.trim(), - path: pathFilter.trim(), - start_time: startTimeFilter, - end_time: endTimeFilter, - }) - } + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + setSearchParams({ + username: usernameFilter.trim(), + path: pathFilter.trim(), + start_time: startTimeFilter, + end_time: endTimeFilter, + }); + }; - const handleReset = () => { - setUsernameFilter("") - setPathFilter("") - setStartTimeFilter("") - setEndTimeFilter("") - setPage(1) - setSearchParams({ - username: "", - path: "", - start_time: "", - end_time: "", - }) - } + const handleReset = () => { + setUsernameFilter(''); + setPathFilter(''); + setStartTimeFilter(''); + setEndTimeFilter(''); + setPage(1); + setSearchParams({ + username: '', + path: '', + start_time: '', + end_time: '', + }); + }; - const handlePageChange = (newPage: number) => { - setPage(newPage) - } + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; - const copyToClipboard = (text: string, subject: string) => { - navigator.clipboard.writeText(text) - toast.success(`${subject}已复制到剪贴板`) - } + const copyToClipboard = (text: string, subject: string) => { + navigator.clipboard.writeText(text); + toast.success(`${subject}已复制到剪贴板`); + }; - // Prettify Headers JSON string - const getPrettyHeaders = (headersRaw?: string) => { - if (!headersRaw) return "暂无头部数据" - try { - const parsed = JSON.parse(headersRaw) - return JSON.stringify(parsed, null, 2) - } catch { - return headersRaw - } - } + // Prettify Headers JSON string + const getPrettyHeaders = (headersRaw?: string) => { + if (!headersRaw) return '暂无头部数据'; + try { + const parsed = JSON.parse(headersRaw); + return JSON.stringify(parsed, null, 2); + } catch { + return headersRaw; + } + }; - if (clickhouseDisabled) { - return ( -
- -

ClickHouse 未启用

-

- 当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。 -

-
- ) - } + if (clickhouseDisabled) { + return ( +
+ +

ClickHouse 未启用

+

+ 当前系统配置未启用 ClickHouse + 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` + 配置文件中配置并启用 ClickHouse。 +

+
+ ); + } - if (error) { - return void logsQuery.refetch()} /> - } + if (error) { + return ( + void logsQuery.refetch()} /> + ); + } - return ( -
- {/* Filters */} -
-
- - setUsernameFilter(e.target.value)} - className="h-8 text-xs" - /> -
-
- - setPathFilter(e.target.value)} - className="h-8 text-xs" - /> -
-
- - setStartTimeFilter(e.target.value)} - className="h-8 text-xs" - /> -
-
- - setEndTimeFilter(e.target.value)} - className="h-8 text-xs" - /> -
-
- - -
-
+ return ( +
+ {/* Filters */} +
+
+ + setUsernameFilter(e.target.value)} + className='h-8 text-xs' + /> +
+
+ + setPathFilter(e.target.value)} + className='h-8 text-xs' + /> +
+
+ + setStartTimeFilter(e.target.value)} + className='h-8 text-xs' + /> +
+
+ + setEndTimeFilter(e.target.value)} + className='h-8 text-xs' + /> +
+
+ + +
+
- {/* Loading Table */} - {loading && logs.length === 0 ? ( - - ) : logs.length === 0 ? ( - - ) : ( -
- - - - 请求方法 - 路径 - 用户 - IP - 状态 - 耗时 - 请求时间 - 详情 - - - - {logs.map((log) => ( - - - - {log.method} - - - - {log.path} - - -
- {log.username || "未知用户"} - {log.nickname && ( - ({log.nickname}) - )} -
-
- - {log.ip} - - - - {log.status} - - - - {formatLatency(log.latency)} - - - {formatDateTime(log.created_at)} - - - - -
- ))} -
-
-
- )} + {/* Loading Table */} + {loading && logs.length === 0 ? ( + + ) : logs.length === 0 ? ( + + ) : ( +
+ + + + 请求方法 + 路径 + 用户 + IP + 状态 + 耗时 + 请求时间 + 详情 + + + + {logs.map((log) => ( + + + + {log.method} + + + + {log.path} + + +
+ + {log.username || '未知用户'} + + {log.nickname && ( + + ({log.nickname}) + + )} +
+
+ + {log.ip} + + + + {log.status} + + + + {formatLatency(log.latency)} + + + {formatDateTime(log.created_at)} + + + + +
+ ))} +
+
+
+ )} - {/* Pagination */} - {logs.length > 0 && ( -
-
- 共 {total} 条记录,当前第 {page}/{totalPages} 页 -
-
- - -
-
- )} + {/* Pagination */} + {logs.length > 0 && ( +
+
+ 共 {total} 条记录,当前第 {page}/{totalPages} 页 +
+
+ + +
+
+ )} - {/* Detail Drawer */} - - - - 访问日志详情 - - 访问 ID: {selectedLog?.id} - - + {/* Detail Drawer */} + + + + 访问日志详情 + + 访问 ID: {selectedLog?.id} + + - {selectedLog ? ( -
- {/* Summary Details */} -
-
-
请求方法
-
{selectedLog.method}
-
-
-
响应状态
-
- - {selectedLog.status} - -
-
-
-
耗时
-
{formatLatency(selectedLog.latency)}
-
-
-
IP 地址
-
{selectedLog.ip}
-
-
-
用户
-
- {selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"} - ID: {selectedLog.user_id} -
-
-
+ {selectedLog ? ( +
+ {/* Summary Details */} +
+
+
+ 请求方法 +
+
+ {selectedLog.method} +
+
+
+
+ 响应状态 +
+
+ + {selectedLog.status} + +
+
+
+
+ 耗时 +
+
+ {formatLatency(selectedLog.latency)} +
+
+
+
+ IP 地址 +
+
{selectedLog.ip}
+
+
+
+ 用户 +
+
+ {selectedLog.username + ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` + : '未知/游客'} + + ID: {selectedLog.user_id} + +
+
+
- {/* Path */} -
- -
- {selectedLog.path} -
-
+ {/* Path */} +
+ +
+ {selectedLog.path} +
+
- {/* Request Time */} -
- -
{formatDateTime(selectedLog.created_at)}
-
+ {/* Request Time */} +
+ +
+ {formatDateTime(selectedLog.created_at)} +
+
- {/* User Agent */} -
-
- - -
-
- {selectedLog.user_agent} -
-
+ {/* User Agent */} +
+
+ + +
+
+ {selectedLog.user_agent} +
+
- {/* Headers */} -
-
- - -
-
-									{getPrettyHeaders(selectedLog.headers)}
-								
-
-
- ) : ( -
- -
- )} - - -
- ) -} \ No newline at end of file + {/* Headers */} +
+
+ + +
+
+                  {getPrettyHeaders(selectedLog.headers)}
+                
+
+
+ ) : ( +
+ +
+ )} + + +
+ ); +} diff --git a/frontend/app/(main)/admin/logs/components/app-logs.tsx b/frontend/app/(main)/admin/logs/components/app-logs.tsx index 27b84a21..bd51880a 100644 --- a/frontend/app/(main)/admin/logs/components/app-logs.tsx +++ b/frontend/app/(main)/admin/logs/components/app-logs.tsx @@ -14,92 +14,103 @@ See the License for the specific language governing permissions and limitations under the License. */ -"use client" +'use client'; -import {memo, useCallback, useEffect, useRef, useState} from "react" -import {useVirtualizer} from "@tanstack/react-virtual" -import {toast} from "sonner" -import {ArrowDown, ChevronUp, Loader2} from "lucide-react" +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { toast } from 'sonner'; +import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react'; -import services from "@/lib/services" -import {ErrorInline} from "@/components/layout/error" -import {LoadingStateWithBorder} from "@/components/layout/loading" -import {Button} from "@/components/ui/button" +import services from '@/lib/services'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { Button } from '@/components/ui/button'; interface LogEntry { - index: number - data: string + index: number; + data: string; } // Distance (px) from bottom to treat as "at bottom" -const BOTTOM_THRESHOLD = 40 -const LOG_LINE_ESTIMATE_PX = 20 -const LOG_VIRTUAL_OVERSCAN = 24 +const BOTTOM_THRESHOLD = 40; +const LOG_LINE_ESTIMATE_PX = 20; +const LOG_VIRTUAL_OVERSCAN = 24; -const LogLine = memo(function LogLine({data}: {data: string}) { - const level = parseLogLevel(data) - const color = level === "error" - ? "text-red-400" - : level === "warn" - ? "text-yellow-400" - : level === "debug" - ? "text-gray-500" - : "text-gray-300" +const LogLine = memo(function LogLine({ data }: { data: string }) { + const level = parseLogLevel(data); + const color = + level === 'error' + ? 'text-red-400' + : level === 'warn' + ? 'text-yellow-400' + : level === 'debug' + ? 'text-gray-500' + : 'text-gray-300'; return ( -
+
{data}
- ) -}) + ); +}); function getApiBaseUrl(): string { - if (typeof window !== "undefined") { + if (typeof window !== 'undefined') { // If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin. - const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || "" - if (base.startsWith("http")) return base + const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ''; + if (base.startsWith('http')) return base; // Relative URL fallback - const proto = window.location.protocol - const host = window.location.host - return `${proto}//${host}${base}` + const proto = window.location.protocol; + const host = window.location.host; + return `${proto}//${host}${base}`; } - return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || "" + return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ''; } function buildWsUrl(): string { - const base = getApiBaseUrl() - const wsBase = base.replace(/^http/, "ws") - return `${wsBase}/api/v1/admin/logs/ws` + const base = getApiBaseUrl(); + const wsBase = base.replace(/^http/, 'ws'); + return `${wsBase}/api/v1/admin/logs/ws`; } -function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" { - const lower = line.toLowerCase() - if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error" - if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn" - if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug" - if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info" - return "unknown" +function parseLogLevel( + line: string, +): 'debug' | 'info' | 'warn' | 'error' | 'unknown' { + const lower = line.toLowerCase(); + if (lower.includes('"level":"error"') || lower.includes('level=error')) + return 'error'; + if (lower.includes('"level":"warn"') || lower.includes('level=warn')) + return 'warn'; + if (lower.includes('"level":"debug"') || lower.includes('level=debug')) + return 'debug'; + if (lower.includes('"level":"info"') || lower.includes('level=info')) + return 'info'; + return 'unknown'; } export function AppLogs() { - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const [logs, setLogs] = useState([]) - const [hasMore, setHasMore] = useState(false) - const [nextCursor, setNextCursor] = useState(0) - const [loadingMore, setLoadingMore] = useState(false) + const [logs, setLogs] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [nextCursor, setNextCursor] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); // autoScroll = true → new logs auto-scroll to bottom - const [autoScroll, setAutoScroll] = useState(true) + const [autoScroll, setAutoScroll] = useState(true); - const containerRef = useRef(null) - const wsRef = useRef(null) - const autoScrollRef = useRef(autoScroll) - const isUserScrolling = useRef(false) + const containerRef = useRef(null); + const wsRef = useRef(null); + const autoScrollRef = useRef(autoScroll); + const isUserScrolling = useRef(false); - useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll]) + useEffect(() => { + autoScrollRef.current = autoScroll; + }, [autoScroll]); const rowVirtualizer = useVirtualizer({ count: logs.length, @@ -107,200 +118,222 @@ export function AppLogs() { estimateSize: () => LOG_LINE_ESTIMATE_PX, overscan: LOG_VIRTUAL_OVERSCAN, measureElement: (element) => element.getBoundingClientRect().height, - }) + }); // ---- Scroll detection ------------------------------------------------ const handleScroll = useCallback(() => { - const el = containerRef.current - if (!el) return + const el = containerRef.current; + if (!el) return; - if (!isUserScrolling.current) return + if (!isUserScrolling.current) return; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD + const atBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD; if (atBottom && !autoScrollRef.current) { - setAutoScroll(true) + setAutoScroll(true); } else if (!atBottom && autoScrollRef.current) { - setAutoScroll(false) + setAutoScroll(false); } - }, []) + }, []); - const handleWheel = useCallback(() => { isUserScrolling.current = true }, []) - const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, []) + const handleWheel = useCallback(() => { + isUserScrolling.current = true; + }, []); + const handleTouchStart = useCallback(() => { + isUserScrolling.current = true; + }, []); useEffect(() => { - const el = containerRef.current - if (!el) return + const el = containerRef.current; + if (!el) return; - let timer: ReturnType + let timer: ReturnType; const onScrollEnd = () => { - clearTimeout(timer) - timer = setTimeout(() => { isUserScrolling.current = false }, 150) - } - el.addEventListener("scroll", onScrollEnd, { passive: true }) + clearTimeout(timer); + timer = setTimeout(() => { + isUserScrolling.current = false; + }, 150); + }; + el.addEventListener('scroll', onScrollEnd, { passive: true }); return () => { - el.removeEventListener("scroll", onScrollEnd) - clearTimeout(timer) - } - }, []) + el.removeEventListener('scroll', onScrollEnd); + clearTimeout(timer); + }; + }, []); // ---- Auto-scroll to bottom when new logs arrive ---------------------- useEffect(() => { - if (!autoScroll || !containerRef.current) return - isUserScrolling.current = false - const el = containerRef.current + if (!autoScroll || !containerRef.current) return; + isUserScrolling.current = false; + const el = containerRef.current; requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight - }) - }, [logs, autoScroll]) + el.scrollTop = el.scrollHeight; + }); + }, [logs, autoScroll]); // ---- Data fetching --------------------------------------------------- const fetchLogs = useCallback(async (cursor: number = 0) => { try { - return await services.adminLog.getLogs(cursor) + return await services.adminLog.getLogs(cursor); } catch (err) { - throw err instanceof Error ? err : new Error("获取日志失败") + throw err instanceof Error ? err : new Error('获取日志失败'); } - }, []) + }, []); - const loadHistory = useCallback(async (cursor: number = 0) => { - const isInitial = cursor === 0 - if (isInitial) { - setLoading(true) - setError(null) - } else { - setLoadingMore(true) - } - - try { - const data = await fetchLogs(cursor) + const loadHistory = useCallback( + async (cursor: number = 0) => { + const isInitial = cursor === 0; if (isInitial) { - setLogs(data.lines || []) + setLoading(true); + setError(null); } else { - const el = containerRef.current - const previousScrollHeight = el?.scrollHeight ?? 0 - setLogs(prev => [...(data.lines || []), ...prev]) + setLoadingMore(true); + } - requestAnimationFrame(() => { - if (!el) return - isUserScrolling.current = false - el.scrollTop = el.scrollHeight - previousScrollHeight - }) + try { + const data = await fetchLogs(cursor); + if (isInitial) { + setLogs(data.lines || []); + } else { + const el = containerRef.current; + const previousScrollHeight = el?.scrollHeight ?? 0; + setLogs((prev) => [...(data.lines || []), ...prev]); + + requestAnimationFrame(() => { + if (!el) return; + isUserScrolling.current = false; + el.scrollTop = el.scrollHeight - previousScrollHeight; + }); + } + setHasMore(data.has_more); + setNextCursor(data.next_cursor); + } catch (err) { + if (isInitial) { + setError(err instanceof Error ? err : new Error('获取日志失败')); + } else { + toast.error('加载更早日志失败'); + } + } finally { + if (isInitial) setLoading(false); + else setLoadingMore(false); } - setHasMore(data.has_more) - setNextCursor(data.next_cursor) - } catch (err) { - if (isInitial) { - setError(err instanceof Error ? err : new Error("获取日志失败")) - } else { - toast.error("加载更早日志失败") - } - } finally { - if (isInitial) setLoading(false) - else setLoadingMore(false) - } - }, [fetchLogs]) + }, + [fetchLogs], + ); // ---- WebSocket ------------------------------------------------------- const connectWs = useCallback(() => { - if (wsRef.current) wsRef.current.close() + if (wsRef.current) wsRef.current.close(); - const ws = new WebSocket(buildWsUrl()) - wsRef.current = ws + const ws = new WebSocket(buildWsUrl()); + wsRef.current = ws; ws.onmessage = (event) => { try { - const msg = JSON.parse(event.data) - if (msg.type === "log" && msg.data) { - const entry: LogEntry = msg.data - setLogs(prev => { - const next = [...prev, entry] - return next.length > 2000 ? next.slice(-2000) : next - }) + const msg = JSON.parse(event.data); + if (msg.type === 'log' && msg.data) { + const entry: LogEntry = msg.data; + setLogs((prev) => { + const next = [...prev, entry]; + return next.length > 2000 ? next.slice(-2000) : next; + }); } - } catch { /* ignore */ } - } + } catch { + /* ignore */ + } + }; - ws.onclose = () => { wsRef.current = null } - }, []) + ws.onclose = () => { + wsRef.current = null; + }; + }, []); // ---- Initialize ------------------------------------------------------ useEffect(() => { - loadHistory(0).then(() => connectWs()) + loadHistory(0).then(() => connectWs()); return () => { - wsRef.current?.close() - wsRef.current = null - } + wsRef.current?.close(); + wsRef.current = null; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); // ---- Actions --------------------------------------------------------- const scrollToBottom = useCallback(() => { - setAutoScroll(true) + setAutoScroll(true); requestAnimationFrame(() => { if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight + containerRef.current.scrollTop = containerRef.current.scrollHeight; } - }) - }, []) + }); + }, []); const handleLoadMore = useCallback(() => { - if (nextCursor > 0) loadHistory(nextCursor) - }, [nextCursor, loadHistory]) + if (nextCursor > 0) loadHistory(nextCursor); + }, [nextCursor, loadHistory]); // ---- Render ---------------------------------------------------------- - if (loading) return - if (error) return loadHistory(0)} /> + if (loading) return ; + if (error) + return loadHistory(0)} />; return ( -
+
{/* Log viewer — fixed height, scrollable */}
{/* Load older logs */} {hasMore && ( -
+
)} {logs.length === 0 ? ( -
暂无日志
+
暂无日志
) : (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { - const entry = logs[virtualRow.index] - if (!entry) return null + const entry = logs[virtualRow.index]; + if (!entry) return null; return (
- ) + ); })}
)} @@ -308,18 +341,18 @@ export function AppLogs() { {/* Floating "back to latest" button */} {!autoScroll && ( -
+
)}
- ) + ); } diff --git a/frontend/app/(main)/admin/logs/page-client.tsx b/frontend/app/(main)/admin/logs/page-client.tsx index 9b73deb1..3f6a5a8f 100644 --- a/frontend/app/(main)/admin/logs/page-client.tsx +++ b/frontend/app/(main)/admin/logs/page-client.tsx @@ -1,66 +1,76 @@ -"use client" +'use client'; -import dynamic from "next/dynamic" -import {Activity, BarChart3, Terminal} from "lucide-react" +import dynamic from 'next/dynamic'; +import { Activity, BarChart3, Terminal } from 'lucide-react'; -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; const tabFallback = ( -
-) +
+); const AccessAnalytics = dynamic( - () => import("./components/access-analytics").then((mod) => mod.AccessAnalytics), + () => + import('./components/access-analytics').then((mod) => mod.AccessAnalytics), { loading: () => tabFallback }, -) +); const AccessLogs = dynamic( - () => import("./components/access-logs").then((mod) => mod.AccessLogs), + () => import('./components/access-logs').then((mod) => mod.AccessLogs), { loading: () => tabFallback }, -) +); const AppLogs = dynamic( - () => import("./components/app-logs").then((mod) => mod.AppLogs), + () => import('./components/app-logs').then((mod) => mod.AppLogs), { loading: () => tabFallback }, -) +); export function LogsPageClient() { return ( -
+
{/* Header */} -
- +
+
-

系统日志

+

系统日志

{/* Tabs Layout */} - - - - + + + + 访问分析 - - + + 用户访问日志 - - + + 应用运行日志 - + - + - +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/(main)/admin/logs/page.tsx b/frontend/app/(main)/admin/logs/page.tsx index 09e44106..50588872 100644 --- a/frontend/app/(main)/admin/logs/page.tsx +++ b/frontend/app/(main)/admin/logs/page.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {LogsPageClient} from "./page-client" +import { LogsPageClient } from './page-client'; export default function LogsPage() { - return -} \ No newline at end of file + return ; +} diff --git a/frontend/app/(main)/admin/push/components/events-tab.tsx b/frontend/app/(main)/admin/push/components/events-tab.tsx index 9803949b..79f52e92 100644 --- a/frontend/app/(main)/admin/push/components/events-tab.tsx +++ b/frontend/app/(main)/admin/push/components/events-tab.tsx @@ -1,21 +1,40 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -"use client" +'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 * 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 { 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, @@ -23,148 +42,170 @@ import { 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" +} 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 { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; - -import services from "@/lib/services" -import {PushService} from "@/lib/services/push" -import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push" +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 queryClient = useQueryClient(); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ - queryKey: ["admin", "push-channels"], + 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]) + .filter((c) => c.enabled) + .map((c) => c.name); + return ['email', ...customChannels]; + }, [channelsQuery.data]); // --- 获取通知事件 --- const eventsQuery = useQuery({ - queryKey: ["admin", "push-events"], + queryKey: ['admin', 'push-events'], queryFn: () => PushService.listEvents(), - }) + }); const builtInEventsQuery = useQuery({ - queryKey: ["admin", "push-builtin-events"], + queryKey: ['admin', 'push-builtin-events'], queryFn: () => PushService.listBuiltInEvents(), - }) + }); // --- 获取所有系统可调度任务类型 --- const taskTypesQuery = useQuery({ - queryKey: ["admin", "task-types"], + queryKey: ['admin', 'task-types'], queryFn: () => services.adminTask.getTaskTypes(), - }) + }); // --- 修改保存事件 Mutation --- const updateEventMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data), + mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => + PushService.updateEvent(id, data), onSuccess: () => { - toast.success("事件更新成功") - queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] }) - setEditEventOpen(false) + toast.success('事件更新成功'); + queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); + setEditEventOpen(false); }, onError: (err: unknown) => { - toast.error("事件更新失败: " + (err as Error).message) + toast.error('事件更新失败: ' + (err as Error).message); }, - }) + }); const toggleEventMutation = useMutation({ mutationFn: (id: number) => PushService.toggleEvent(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, onError: (err: unknown) => { - toast.error("操作失败: " + (err as Error).message) + 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) + 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) + toast.error('事件创建失败: ' + (err as Error).message); }, - }) + }); const deleteEventMutation = useMutation({ mutationFn: (id: number) => PushService.deleteEvent(id), onSuccess: () => { - toast.success("配置删除成功") - queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] }) + toast.success('配置删除成功'); + queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); }, onError: (err: unknown) => { - toast.error("配置删除失败: " + (err as Error).message) + toast.error('配置删除失败: ' + (err as Error).message); }, - }) + }); // --- 事件编辑对话框状态 --- - const [editEventOpen, setEditEventOpen] = React.useState(false) - const [selectedEvent, setSelectedEvent] = React.useState(null) - const [eventChannels, setEventChannels] = React.useState([]) - const [eventTargets, setEventTargets] = React.useState("") - const [eventTemplate, setEventTemplate] = React.useState("") + const [editEventOpen, setEditEventOpen] = React.useState(false); + const [selectedEvent, setSelectedEvent] = React.useState( + null, + ); + const [eventChannels, setEventChannels] = React.useState([]); + 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([]) - const [newEventTargets, setNewEventTargets] = React.useState("") - const [newEventTemplate, setNewEventTemplate] = React.useState("") - const [newEventEnabled, setNewEventEnabled] = React.useState(true) + 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([]); + 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 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) - } + setSelectedEvent(event); + setEventChannels(event.channels); + setEventTargets((event.targets ?? []).join(', ')); + setEventTemplate(event.template); + setEditEventOpen(true); + }; const handleSaveEvent = () => { - if (!selectedEvent) return + if (!selectedEvent) return; try { - JSON.parse(eventTemplate) + JSON.parse(eventTemplate); } catch { - toast.error("消息模板不是合法的 JSON 格式") - return + toast.error('消息模板不是合法的 JSON 格式'); + return; } const targets = eventTargets - .split(",") - .map(t => t.trim()) - .filter(t => t !== "") + .split(',') + .map((t) => t.trim()) + .filter((t) => t !== ''); updateEventMutation.mutate({ id: selectedEvent.id, @@ -174,177 +215,230 @@ export function EventsTab() { template: eventTemplate, enabled: selectedEvent.enabled, }, - }) - } + }); + }; const handleCreateEventClick = () => { - setNewEventKey("") - setNewEventType("builtin") - setNewEventTaskType("") - setNewEventChannels([]) - setNewEventTargets("") - setNewEventTemplate("") - setNewEventEnabled(true) - setCreateEventOpen(true) - } + 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) + setNewEventKey(key); + const ev = availableBuiltInEvents.find((e) => e.key === key); if (ev) { - setNewEventTemplate(JSON.stringify(ev.default_template, null, 2)) + setNewEventTemplate(JSON.stringify(ev.default_template, null, 2)); } else { - setNewEventTemplate("") + setNewEventTemplate(''); } - } + }; const handleNewEventTaskTypeChange = (taskType: string) => { - setNewEventTaskType(taskType) - const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType) + 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)) + level: 'INFO', + }; + setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2)); } else { - setNewEventTemplate("") + setNewEventTemplate(''); } - } + }; const handleCreateEvent = () => { - if (newEventType === "builtin" && !newEventKey) { - toast.error("请选择系统事件") - return + if (newEventType === 'builtin' && !newEventKey) { + toast.error('请选择系统事件'); + return; } - if (newEventType === "task" && !newEventTaskType) { - toast.error("请选择异步任务") - return + if (newEventType === 'task' && !newEventTaskType) { + toast.error('请选择异步任务'); + return; } if (newEventTemplate) { try { - JSON.parse(newEventTemplate) + JSON.parse(newEventTemplate); } catch { - toast.error("内容渲染模板不是合法的 JSON 格式") - return + toast.error('内容渲染模板不是合法的 JSON 格式'); + return; } } const targets = newEventTargets - .split(",") - .map(t => t.trim()) - .filter(t => t !== "") + .split(',') + .map((t) => t.trim()) + .filter((t) => t !== ''); createEventMutation.mutate({ - event_key: newEventType === "builtin" ? newEventKey : undefined, - task_type: newEventType === "task" ? newEventTaskType : undefined, + 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 ( -
-
-
{eventsQuery.isLoading ? ( - + ) : eventsQuery.isError ? ( -
- eventsQuery.refetch()} className="justify-center" /> +
+ eventsQuery.refetch()} + className='justify-center' + />
) : (eventsQuery.data ?? []).length === 0 ? ( - + ) : ( -
- - - - ID - 通知事件 - 关联渠道 - 推送目标 - 状态 - 操作 +
+
+ + + + ID + + + 通知事件 + + + 关联渠道 + + + 推送目标 + + + 状态 + + + 操作 + {(eventsQuery.data ?? []).map((event) => ( handleEditEventClick(event)} > - {event.id} - -
-
- {event.name} + + {event.id} + + +
+
+ + {event.name} + {event.task_type && ( - + 任务 )}
- {event.event_key} + + {event.event_key} +
- -
+ +
{(event.channels ?? []).length === 0 ? ( - 未指定渠道 + + 未指定渠道 + ) : ( - event.channels.map(ch => ( - - {ch === "email" ? "邮件" : ch} + event.channels.map((ch) => ( + + {ch === 'email' ? '邮件' : ch} )) )}
- -
- {(!event.targets || event.targets.length === 0) ? ( - - + +
+ {!event.targets || event.targets.length === 0 ? ( + + - + ) : ( - event.targets.map(t => ( - + event.targets.map((t) => ( + {t} )) )}
- e.stopPropagation()}> + e.stopPropagation()} + > toggleEventMutation.mutate(event.id)} - className="scale-75" + onCheckedChange={() => + toggleEventMutation.mutate(event.id) + } + className='scale-75' /> - e.stopPropagation()}> -
+ e.stopPropagation()} + > +
- + 配置 @@ -354,20 +448,20 @@ export function EventsTab() { - + 删除 @@ -383,7 +477,7 @@ export function EventsTab() { {/* ==================== 对话框:新增事件 ==================== */} - + 新增通知事件 @@ -391,61 +485,68 @@ export function EventsTab() { -
-
- -
-