mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
perf(frontend): split admin bundles and tighten data fetching
Add next/dynamic lazy loading for database, logs, and settings heavy modules; migrate access-logs and task-executions to React Query; parallelize login auth-sources with public config; consolidate users table tooltips; enable lazy image decoding; and replace barrel @/lib/services imports with direct module paths.
This commit is contained in:
+23
-17
@@ -13,6 +13,7 @@
|
||||
| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
|
||||
| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ |
|
||||
| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ |
|
||||
| P0 前端 #12–#19 | dynamic 分割、React Query、登录并行、Tooltip、lazy、barrel 收窄 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,7 +73,7 @@ flowchart LR
|
||||
|
||||
当前最大的结构性问题(2026-06-17 更新):
|
||||
|
||||
1. **前端**:全客户端渲染仍为主模式;~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态。Admin 大 bundle、无 `dynamic()` 分割仍待优化。
|
||||
1. **前端**:~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态;~~Admin 重模块无 `dynamic()` 分割~~ ✅ database/logs/settings 已懒加载子模块。其余路由 `page.tsx` 仍为 `"use client"`(静态导出下 RSC 收益有限,待逐步薄壳化)。
|
||||
2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
|
||||
3. **参数中心**:~~`GetByKey` 每次直打 Redis~~ ✅ 已加 Otter v2 进程内缓存(`pkg/cache/ram`),读路径为 RAM → Redis → DB;管理员写配置后统一失效 RAM + Redis,并通过 pub/sub 同步多节点本地缓存。
|
||||
|
||||
@@ -333,13 +334,13 @@ if (loading || !user) {
|
||||
| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 |
|
||||
| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 |
|
||||
| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 |
|
||||
| 13 | 无 `next/dynamic` 代码分割 | 全项目 | Admin 首包 300–450KB+ |
|
||||
| 14 | 19/24 个 `page.tsx` 为 `"use client"` | 各路由 | 无法 RSC 预取,bundle 偏大 |
|
||||
| 15 | Admin 部分页面用 `useEffect` 而非 React Query | `access-logs.tsx`, `task-executions.tsx` 等 | 无缓存去重,重复请求 |
|
||||
| 16 | 登录页 OIDC sources 等待 public config | `frontend/components/auth/login-form.tsx` | 多 1 次 RTT 瀑布 |
|
||||
| 17 | Users 表每行嵌套 3 个 `TooltipProvider` | `frontend/app/(main)/admin/users/page.tsx` | 50+ 行时不必要重渲染 |
|
||||
| 18 | 缩略图用原生 `<img>` 无 lazy loading | `file-list.tsx`, `file-manager.tsx` | 文件管理页初始解码压力大 |
|
||||
| 19 | `@/lib/services` barrel 导入 | ~40 个文件 | 单路由 bundle 膨胀 10–30KB |
|
||||
| 13 | ~~无 `next/dynamic` 代码分割~~ ✅ | `database/`, `logs/`, `settings/` page-client | Admin 重模块拆分为独立 chunk |
|
||||
| 14 | 19/24 个 `page.tsx` 为 `"use client"` `🔶` | 各路由 | database/logs/settings 已薄壳化;其余待迁移 |
|
||||
| 15 | ~~Admin 部分页面用 `useEffect` 而非 React Query~~ ✅ | `access-logs.tsx`, `task-executions.tsx` | 列表/详情走 React Query 缓存去重 |
|
||||
| 16 | ~~登录页 OIDC sources 等待 public config~~ ✅ | `login-form.tsx` | public config 与 auth sources 并行请求 |
|
||||
| 17 | ~~Users 表每行嵌套 3 个 `TooltipProvider`~~ ✅ | `admin/users/page.tsx` | 表格外层单一 Provider |
|
||||
| 18 | ~~缩略图用原生 `<img>` 无 lazy loading~~ ✅ | `file-list.tsx`, `file-manager.tsx` | `loading="lazy"` + `decoding="async"` |
|
||||
| 19 | ~~`@/lib/services` barrel 导入~~ ✅ | 全前端消费侧 | 改为 `@/lib/services/<module>` 直接导入 |
|
||||
| 20 | SQLite 模式无连接池调优 | `internal/db/postgres.go` | 默认 SQLite 写锁瓶颈 |
|
||||
| 21 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 |
|
||||
|
||||
@@ -357,7 +358,7 @@ if (loading || !user) {
|
||||
| 4 | `GetFileStats` 增量统计表 | `stats.go`, `w_upload_stats` | Admin 统计从 O(n) → O(1) | 低 | ✅ |
|
||||
| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 | ✅ |
|
||||
| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 | ✅ |
|
||||
| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page.tsx`, `logs/page.tsx`, `settings/page.tsx` 等 | 首包 JS ↓ 150–300KB | 低 | ⬜ |
|
||||
| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page-client.tsx`, `logs/page-client.tsx`, `settings/page-client.tsx` | 首包 JS ↓ 150–300KB | 低 | ✅ |
|
||||
|
||||
### P1 — 短期(2–4 周)
|
||||
|
||||
@@ -369,8 +370,8 @@ if (loading || !user) {
|
||||
| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ |
|
||||
| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
|
||||
| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
|
||||
| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | ⬜ |
|
||||
| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ⬜ |
|
||||
| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | 🔶 access-logs / task-executions 已完成 |
|
||||
| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ✅ |
|
||||
| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
|
||||
|
||||
### P2 — 中期架构演进
|
||||
@@ -382,9 +383,9 @@ if (loading || !user) {
|
||||
| 18 | 推送通知 target 批量解析(`WHERE id IN ?`) | 通知风暴 DB 查询 ↓ N 倍 |
|
||||
| 19 | 上传清理改为批量 UPDATE + 异步存储删除 | 减少 DB commit 频率 |
|
||||
| 20 | 路由动画 0.5s → 0.15s 或纯 CSS | 导航感知速度 ↑ |
|
||||
| 21 | 服务导入收窄(直接 import 具体 Service) | 每路由 bundle ↓ 10–30KB |
|
||||
| 21 | ~~服务导入收窄(直接 import 具体 Service)~~ ✅ | 每路由 bundle ↓ 10–30KB |
|
||||
| 22 | Admin 路由级 `loading.tsx` + Suspense | 渐进式渲染体验 |
|
||||
| 23 | 缩略图 `loading="lazy"` + 固定尺寸 | 文件管理页初始 paint 加速 |
|
||||
| 23 | ~~缩略图 `loading="lazy"` + 固定尺寸~~ ✅ | 文件管理页初始 paint 加速 |
|
||||
|
||||
---
|
||||
|
||||
@@ -427,7 +428,7 @@ if (loading || !user) {
|
||||
|------|-------------|-----------|
|
||||
| 图片站 / 公开相册 | WebP miss(锁/白名单已优化) | P0 #1 预热待做 |
|
||||
| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
|
||||
| 管理端日常使用 | 大 bundle(认证/日志已优化) | P0 #7 dynamic import |
|
||||
| 管理端日常使用 | ~~大 bundle~~(dynamic 分割 + barrel 收窄已落地) | P2 #22 路由 loading.tsx |
|
||||
| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
|
||||
| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC |
|
||||
| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 |
|
||||
@@ -442,7 +443,7 @@ if (loading || !user) {
|
||||
|
||||
1. ~~**WebP 路径解耦**~~ ✅ `singleflight` + `RWMutex` 已落地;**下一步**:部署后预热 + miss 异步回退原图
|
||||
2. ~~**文件路径查询缓存**~~ ✅ 迁移状态 + 白名单进程内缓存已落地
|
||||
3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;**下一步**:Admin `dynamic()` 代码分割
|
||||
3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;~~Admin `dynamic()` 代码分割~~ ✅ 已落地;**下一步**:其余 Admin 路由薄壳化 + `loading.tsx`
|
||||
|
||||
### 实施检查清单
|
||||
|
||||
@@ -458,15 +459,20 @@ P0 后端
|
||||
|
||||
P0 前端
|
||||
[x] app-logs.tsx 虚拟滚动 ✅ 2026-06-17
|
||||
[ ] SQLConsole / Recharts / Settings Tabs dynamic import
|
||||
[x] SQLConsole / Settings Tabs / Logs Tabs dynamic import ✅ 2026-06-17
|
||||
[x] 认证 gate 并行化 ✅ 2026-06-17
|
||||
[x] 登录页 public config + auth sources 并行 ✅ 2026-06-17
|
||||
[x] access-logs / task-executions → React Query ✅ 2026-06-17
|
||||
[x] Users TooltipProvider 合并 ✅ 2026-06-17
|
||||
[x] 缩略图 loading="lazy" ✅ 2026-06-17
|
||||
[x] @/lib/services barrel 导入收窄 ✅ 2026-06-17
|
||||
|
||||
P1
|
||||
[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
|
||||
[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17
|
||||
[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17
|
||||
[ ] OIDC Provider 缓存
|
||||
[ ] Admin useEffect → React Query 统一
|
||||
[ ] Admin useEffect → React Query 统一(database overview 等待)
|
||||
[ ] 状态轮询 visibility 感知
|
||||
[ ] Server Component session 预取
|
||||
```
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
import type {DBOverview} from "@/lib/services/db-manage"
|
||||
|
||||
const sectionFallback = (
|
||||
<div className="h-48 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const TableBrowser = dynamic(
|
||||
() => import("@/components/common/admin/database-table-browser").then((mod) => mod.TableBrowser),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const CacheManager = dynamic(
|
||||
() => import("@/components/common/admin/database-cache-manager").then((mod) => mod.CacheManager),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const SQLConsole = dynamic(
|
||||
() => import("@/components/common/admin/database-sql-console").then((mod) => mod.SQLConsole),
|
||||
{ ssr: false },
|
||||
)
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
}
|
||||
|
||||
export function DatabasePageClient() {
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null)
|
||||
const [tables, setTables] = useState<string[]>([])
|
||||
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
|
||||
const [loadingTables, setLoadingTables] = useState<boolean>(true)
|
||||
const [exporting, setExporting] = useState<boolean>(false)
|
||||
|
||||
// 协调子组件刷新与视图切换状态
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState<number>(0)
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
try {
|
||||
const data = await DbManageService.getOverview()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
try {
|
||||
const data = await DbManageService.listTables()
|
||||
setTables(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}
|
||||
|
||||
// 4. 导出数据库备份
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await AdminService.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "导出异常",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
|
||||
// 渲染概览卡片骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/40 bg-card/50">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染 SQL 查询控台全屏视图
|
||||
if (showConsole) {
|
||||
return (
|
||||
<SQLConsole
|
||||
dbType={overview?.type}
|
||||
onClose={() => setShowConsole(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 正常页面排版布局
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">数据管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={loadingOverview}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingOverview ? "animate-spin" : ""}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 概览指标卡片组 */}
|
||||
{loadingOverview ? (
|
||||
renderOverviewSkeleton()
|
||||
) : (
|
||||
overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* 卡片1: 数据库类型 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库类型</CardDescription>
|
||||
<Server className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold uppercase">{overview.type}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片2: 数据库版本 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">版本信息</CardDescription>
|
||||
<Cpu className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.version}>
|
||||
{overview.version.split(" ").slice(0, 2).join(" ")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片3: 数据库名称 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">名称/路径</CardDescription>
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.name}>
|
||||
{overview.name.substring(overview.name.lastIndexOf("/") + 1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片4: 数据库大小 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库大小</CardDescription>
|
||||
<HardDrive className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{overview.size}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片5: 表数量 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">物理数据表</CardDescription>
|
||||
<Layers className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.table_count)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片6: 连接数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">活跃连接数</CardDescription>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.connections)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 2. 数据表浏览器区块 */}
|
||||
<TableBrowser
|
||||
tables={tables}
|
||||
loadingTables={loadingTables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 3. 缓存管理区块 */}
|
||||
<CacheManager
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 4. 底部功能卡片区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
<CardTitle className="text-sm font-semibold">功能区</CardTitle>
|
||||
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 功能一: 数据库导出 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Download className="size-4 text-primary" />
|
||||
数据库备份导出
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
|
||||
{exporting ? "正在准备..." : "开始导出"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 功能二: SQL 查询终端 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
SQL 查询控台
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95"
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,313 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import type {DBOverview} from "@/lib/services"
|
||||
import services, {AdminService} from "@/lib/services"
|
||||
|
||||
import {TableBrowser} from "@/components/common/admin/database-table-browser"
|
||||
import {CacheManager} from "@/components/common/admin/database-cache-manager"
|
||||
import {SQLConsole} from "@/components/common/admin/database-sql-console"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
import {DatabasePageClient} from "./page-client"
|
||||
|
||||
export default function DatabasePage() {
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null)
|
||||
const [tables, setTables] = useState<string[]>([])
|
||||
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
|
||||
const [loadingTables, setLoadingTables] = useState<boolean>(true)
|
||||
const [exporting, setExporting] = useState<boolean>(false)
|
||||
|
||||
// 协调子组件刷新与视图切换状态
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState<number>(0)
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
try {
|
||||
const data = await services.dbManage.getOverview()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
try {
|
||||
const data = await services.dbManage.listTables()
|
||||
setTables(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}
|
||||
|
||||
// 4. 导出数据库备份
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await AdminService.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "导出异常",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
|
||||
// 渲染概览卡片骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/40 bg-card/50">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染 SQL 查询控台全屏视图
|
||||
if (showConsole) {
|
||||
return (
|
||||
<SQLConsole
|
||||
dbType={overview?.type}
|
||||
onClose={() => setShowConsole(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 正常页面排版布局
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">数据管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={loadingOverview}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingOverview ? "animate-spin" : ""}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 概览指标卡片组 */}
|
||||
{loadingOverview ? (
|
||||
renderOverviewSkeleton()
|
||||
) : (
|
||||
overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* 卡片1: 数据库类型 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库类型</CardDescription>
|
||||
<Server className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold uppercase">{overview.type}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片2: 数据库版本 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">版本信息</CardDescription>
|
||||
<Cpu className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.version}>
|
||||
{overview.version.split(" ").slice(0, 2).join(" ")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片3: 数据库名称 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">名称/路径</CardDescription>
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.name}>
|
||||
{overview.name.substring(overview.name.lastIndexOf("/") + 1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片4: 数据库大小 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库大小</CardDescription>
|
||||
<HardDrive className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{overview.size}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片5: 表数量 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">物理数据表</CardDescription>
|
||||
<Layers className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.table_count)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片6: 连接数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">活跃连接数</CardDescription>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.connections)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 2. 数据表浏览器区块 */}
|
||||
<TableBrowser
|
||||
tables={tables}
|
||||
loadingTables={loadingTables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 3. 缓存管理区块 */}
|
||||
<CacheManager
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 4. 底部功能卡片区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
<CardTitle className="text-sm font-semibold">功能区</CardTitle>
|
||||
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 功能一: 数据库导出 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Download className="size-4 text-primary" />
|
||||
数据库备份导出
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
|
||||
{exporting ? "正在准备..." : "开始导出"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 功能二: SQL 查询终端 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
SQL 查询控台
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95"
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <DatabasePageClient />
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {Activity, BarChart3, Terminal} from "lucide-react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
|
||||
const tabFallback = (
|
||||
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const AccessAnalytics = dynamic(
|
||||
() => import("@/components/common/admin/access-analytics").then((mod) => mod.AccessAnalytics),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AccessLogs = dynamic(
|
||||
() => import("@/components/common/admin/access-logs").then((mod) => mod.AccessLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AppLogs = dynamic(
|
||||
() => import("@/components/common/admin/app-logs").then((mod) => mod.AppLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
export function LogsPageClient() {
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs Layout */}
|
||||
<Tabs defaultValue="analytics" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="analytics" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
访问分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
用户访问日志
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
应用运行日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analytics" className="mt-0 outline-none flex-1">
|
||||
<AccessAnalytics />
|
||||
</TabsContent>
|
||||
<TabsContent value="access" className="mt-0 outline-none flex-1">
|
||||
<AccessLogs />
|
||||
</TabsContent>
|
||||
<TabsContent value="app" className="mt-0 outline-none flex-1">
|
||||
<AppLogs />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,53 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {Activity, BarChart3, Terminal} from "lucide-react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {AppLogs} from "@/components/common/admin/app-logs"
|
||||
import {AccessLogs} from "@/components/common/admin/access-logs"
|
||||
import {AccessAnalytics} from "@/components/common/admin/access-analytics"
|
||||
import {LogsPageClient} from "./page-client"
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs Layout */}
|
||||
<Tabs defaultValue="analytics" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="analytics" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
访问分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
用户访问日志
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
应用运行日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analytics" className="mt-0 outline-none flex-1">
|
||||
<AccessAnalytics />
|
||||
</TabsContent>
|
||||
<TabsContent value="access" className="mt-0 outline-none flex-1">
|
||||
<AccessLogs />
|
||||
</TabsContent>
|
||||
<TabsContent value="app" className="mt-0 outline-none flex-1">
|
||||
<AppLogs />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <LogsPageClient />
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {useEffect, useMemo} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Loader2, Settings} from "lucide-react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
|
||||
const tabFallback = (
|
||||
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const SecurityTab = dynamic(
|
||||
() => import("@/components/common/settings/security-tab").then((mod) => mod.SecurityTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OperationTab = dynamic(
|
||||
() => import("@/components/common/settings/operation-tab").then((mod) => mod.OperationTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const SystemTab = dynamic(
|
||||
() => import("@/components/common/settings/system-tab").then((mod) => mod.SystemTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OtherTab = dynamic(
|
||||
() => import("@/components/common/settings/other-tab").then((mod) => mod.OtherTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const InfoTab = dynamic(
|
||||
() => import("@/components/common/settings/info-tab").then((mod) => mod.InfoTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const SystemStatusManager = dynamic(
|
||||
() => import("@/components/common/admin/status").then((mod) => mod.SystemStatusManager),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
function systemConfigMap(configs: SystemConfig[]) {
|
||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||
accumulator[config.key] = config
|
||||
return accumulator
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function AdminSettingsPageClient() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
queryFn: () => AdminService.listSystemConfigs("system"),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
const configs = useMemo(
|
||||
() => systemConfigMap(systemConfigsQuery.data ?? []),
|
||||
[systemConfigsQuery.data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!user || !user.is_admin)) {
|
||||
router.replace("/settings/profile")
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
|
||||
业务设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
|
||||
其他设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统状态
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统信息
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="security" className="focus-visible:outline-none">
|
||||
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="operation" className="focus-visible:outline-none">
|
||||
<OperationTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="system" className="focus-visible:outline-none">
|
||||
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="focus-visible:outline-none">
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="focus-visible:outline-none">
|
||||
<OtherTab configs={configs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info" className="focus-visible:outline-none">
|
||||
<InfoTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +1,8 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import {useEffect, useMemo} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Loader2, Settings} from "lucide-react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {SystemStatusManager} from "@/components/common/admin/status"
|
||||
|
||||
// 引入位于 components/common/settings/ 目录下的具体业务 Tab
|
||||
import {SecurityTab} from "@/components/common/settings/security-tab"
|
||||
import {OperationTab} from "@/components/common/settings/operation-tab"
|
||||
import {SystemTab} from "@/components/common/settings/system-tab"
|
||||
import {OtherTab} from "@/components/common/settings/other-tab"
|
||||
import {InfoTab} from "@/components/common/settings/info-tab"
|
||||
|
||||
function systemConfigMap(configs: SystemConfig[]) {
|
||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||
accumulator[config.key] = config
|
||||
return accumulator
|
||||
}, {})
|
||||
}
|
||||
import {AdminSettingsPageClient} from "./page-client"
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
queryFn: () => AdminService.listSystemConfigs("system"),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
const configs = useMemo(
|
||||
() => systemConfigMap(systemConfigsQuery.data ?? []),
|
||||
[systemConfigsQuery.data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!user || !user.is_admin)) {
|
||||
router.replace("/settings/profile")
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
|
||||
业务设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
|
||||
其他设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统状态
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统信息
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="security" className="focus-visible:outline-none">
|
||||
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="operation" className="focus-visible:outline-none">
|
||||
<OperationTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="system" className="focus-visible:outline-none">
|
||||
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="focus-visible:outline-none">
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="focus-visible:outline-none">
|
||||
<OtherTab configs={configs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info" className="focus-visible:outline-none">
|
||||
<InfoTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
return <AdminSettingsPageClient />
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {ShieldCheck} from "lucide-react"
|
||||
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import type {SystemConfig} from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {AdminProvider, useAdmin} from "@/contexts/admin-context"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import {AdminUser} from "@/lib/services"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
@@ -120,6 +120,7 @@ export default function UsersPage() {
|
||||
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
@@ -174,7 +175,6 @@ export default function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
@@ -190,9 +190,7 @@ export default function UsersPage() {
|
||||
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
|
||||
@@ -203,10 +201,8 @@ export default function UsersPage() {
|
||||
查看详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!user.is_admin && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -222,7 +218,6 @@ export default function UsersPage() {
|
||||
删除用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -230,6 +225,7 @@ export default function UsersPage() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {CapWidget} from "@/components/auth/cap-widget"
|
||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||
import {OTPForm} from "./otp-form"
|
||||
import services from "@/lib/services"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {ConfigService} from "@/lib/services/config"
|
||||
import type {LoginRequest} from "@/lib/services/auth/types"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
@@ -64,13 +65,12 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
|
||||
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => services.config.getPublicConfig(),
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth-sources"],
|
||||
queryFn: () => services.auth.getAuthSources(),
|
||||
enabled: configBool(publicConfigQuery.data?.oidc_login_enabled, true),
|
||||
queryFn: () => AuthService.getAuthSources(),
|
||||
})
|
||||
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
|
||||
@@ -85,7 +85,7 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return services.auth.login(req, Object.keys(headers).length ? headers : undefined)
|
||||
return AuthService.login(req, Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
@@ -170,7 +170,7 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
|
||||
try {
|
||||
setErrorMessage("")
|
||||
persistRedirectTarget(searchParams)
|
||||
const { authorize_url } = await services.auth.getAuthorizeUrl(sourceName)
|
||||
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName)
|
||||
window.location.href = authorize_url
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "第三方登录失败")
|
||||
@@ -194,7 +194,8 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
|
||||
configBool(publicConfigQuery.data?.password_register_enabled, true)
|
||||
|
||||
const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true)
|
||||
const authSources = authSourcesQuery.data ?? []
|
||||
const oidcLoginEnabled = configBool(publicConfigQuery.data?.oidc_login_enabled, true)
|
||||
const authSources = oidcLoginEnabled ? (authSourcesQuery.data ?? []) : []
|
||||
|
||||
const loginDisabled =
|
||||
!passwordLoginEnabled ||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {LoginForm} from "@/components/auth/login-form"
|
||||
import {AuthShell} from "@/components/auth/auth-shell"
|
||||
import {Check} from "lucide-react"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function LoginPage() {
|
||||
|
||||
setIsProcessingCallback(true)
|
||||
try {
|
||||
const result = await services.auth.handleCallback({ state, code })
|
||||
const result = await AuthService.handleCallback({ state, code })
|
||||
if (result.status === "need_bind") {
|
||||
toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。")
|
||||
setIsProcessingCallback(false)
|
||||
|
||||
@@ -13,7 +13,8 @@ import {Spinner} from "@/components/ui/spinner"
|
||||
import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||
import {CapWidget} from "@/components/auth/cap-widget"
|
||||
import services from "@/lib/services"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {ConfigService} from "@/lib/services/config"
|
||||
import type {RegisterRequest} from "@/lib/services/auth/types"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
@@ -53,7 +54,7 @@ export function RegisterForm() {
|
||||
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => services.config.getPublicConfig(),
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
const redirectTarget = useMemo(
|
||||
@@ -110,7 +111,7 @@ export function RegisterForm() {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return services.auth.register(req, Object.keys(headers).length ? headers : undefined)
|
||||
return AuthService.register(req, Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
@@ -135,7 +136,7 @@ export function RegisterForm() {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return services.auth.sendEmailCode(targetEmail, "register", Object.keys(headers).length ? headers : undefined)
|
||||
return AuthService.sendEmailCode(targetEmail, "register", Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setRegisterCooldown(60)
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
@@ -16,12 +16,13 @@ limitations under the License.
|
||||
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "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 {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
@@ -47,6 +48,8 @@ interface AccessLog {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
@@ -66,15 +69,13 @@ function statusVariant(status: number) {
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
|
||||
function isClickhouseDisabledError(error: Error) {
|
||||
const errMsg = error.message || ""
|
||||
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
|
||||
}
|
||||
|
||||
const [logs, setLogs] = useState<AccessLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
export function AccessLogs() {
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 15
|
||||
|
||||
// 搜索过滤条件
|
||||
const [usernameFilter, setUsernameFilter] = useState("")
|
||||
@@ -93,12 +94,9 @@ export function AccessLogs() {
|
||||
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const fetchAccessLogs = useCallback(async (currentPage: number = page) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// 转换起止时间格式
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ["admin", "access-logs", page, searchParams],
|
||||
queryFn: async () => {
|
||||
let startISO = ""
|
||||
if (searchParams.start_time) {
|
||||
startISO = new Date(searchParams.start_time).toISOString()
|
||||
@@ -108,34 +106,25 @@ export function AccessLogs() {
|
||||
endISO = new Date(searchParams.end_time).toISOString()
|
||||
}
|
||||
|
||||
const data = await AdminService.getAccessLogs({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
return AdminService.getAccessLogs({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
username: searchParams.username || undefined,
|
||||
path: searchParams.path || undefined,
|
||||
start_time: startISO || undefined,
|
||||
end_time: endISO || undefined,
|
||||
})
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
setLogs(data.list || [])
|
||||
setTotal(data.total || 0)
|
||||
setClickhouseDisabled(false)
|
||||
} catch (err) {
|
||||
const errorInstance = err instanceof Error ? err : new Error("获取访问日志失败")
|
||||
const errMsg = errorInstance.message || ""
|
||||
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
|
||||
setClickhouseDisabled(true)
|
||||
} else {
|
||||
setError(errorInstance)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, searchParams])
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccessLogs()
|
||||
}, [fetchAccessLogs])
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -164,7 +153,6 @@ export function AccessLogs() {
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
fetchAccessLogs(newPage)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
@@ -172,8 +160,6 @@ export function AccessLogs() {
|
||||
toast.success(`${subject}已复制到剪贴板`)
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return "暂无头部数据"
|
||||
@@ -198,7 +184,7 @@ export function AccessLogs() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={() => fetchAccessLogs(1)} />
|
||||
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -465,4 +451,4 @@ export function AccessLogs() {
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {useVirtualizer} from "@tanstack/react-virtual"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
|
||||
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import type {CacheStatus} from "@/lib/services/admin"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
|
||||
@@ -11,8 +11,8 @@ 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"
|
||||
import services from "@/lib/services"
|
||||
import type {ExecuteSQLResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
@@ -81,7 +81,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
try {
|
||||
const result = await services.dbManage.executeSQL(sqlQuery)
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
} catch (err) {
|
||||
|
||||
@@ -10,8 +10,8 @@ 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"
|
||||
import services from "@/lib/services"
|
||||
import type {TableDataResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
@@ -55,7 +55,7 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
if (!tableName) return
|
||||
setLoadingData(true)
|
||||
try {
|
||||
const data = await services.dbManage.getTableData({
|
||||
const data = await DbManageService.getTableData({
|
||||
table: tableName,
|
||||
page: targetPage,
|
||||
pageSize: size,
|
||||
|
||||
@@ -292,6 +292,8 @@ export function FileList() {
|
||||
<img
|
||||
src={getFileUrl(file.id, "low") ?? undefined}
|
||||
alt={file.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||||
@@ -408,6 +410,8 @@ export function FileList() {
|
||||
<img
|
||||
src={getFileUrl(detailTarget.id, "low") ?? undefined}
|
||||
alt={detailTarget.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -33,7 +33,8 @@ import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
|
||||
|
||||
export function EventsTab() {
|
||||
@@ -42,7 +43,7 @@ export function EventsTab() {
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => services.push.listChannels(),
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
const availableChannels = React.useMemo(() => {
|
||||
@@ -55,23 +56,23 @@ export function EventsTab() {
|
||||
// --- 获取通知事件 ---
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-events"],
|
||||
queryFn: () => services.push.listEvents(),
|
||||
queryFn: () => PushService.listEvents(),
|
||||
})
|
||||
|
||||
const builtInEventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-builtin-events"],
|
||||
queryFn: () => services.push.listBuiltInEvents(),
|
||||
queryFn: () => PushService.listBuiltInEvents(),
|
||||
})
|
||||
|
||||
// --- 获取所有系统可调度任务类型 ---
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => services.admin.getTaskTypes(),
|
||||
queryFn: () => AdminService.getTaskTypes(),
|
||||
})
|
||||
|
||||
// --- 修改保存事件 Mutation ---
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
@@ -83,7 +84,7 @@ export function EventsTab() {
|
||||
})
|
||||
|
||||
const toggleEventMutation = useMutation({
|
||||
mutationFn: (id: number) => services.push.toggleEvent(id),
|
||||
mutationFn: (id: number) => PushService.toggleEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
@@ -93,7 +94,7 @@ export function EventsTab() {
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => services.push.createEvent(data),
|
||||
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
@@ -110,7 +111,7 @@ export function EventsTab() {
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => services.push.deleteEvent(id),
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
toast.success("配置删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {PushHistory} from "@/lib/services/push"
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
@@ -56,7 +56,7 @@ export function HistoriesTab() {
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryFn: () =>
|
||||
services.push.listHistories({
|
||||
PushService.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
|
||||
@@ -29,7 +29,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
|
||||
|
||||
export function SettingsTab() {
|
||||
@@ -38,19 +38,19 @@ export function SettingsTab() {
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => services.push.listChannels(),
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
// --- 获取动态通道表单字段定义 ---
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels-definitions"],
|
||||
queryFn: () => services.push.listChannelDefinitions(),
|
||||
queryFn: () => PushService.listChannelDefinitions(),
|
||||
})
|
||||
|
||||
|
||||
// --- 消息通道 CRUD Mutations ---
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => services.push.createChannel(data),
|
||||
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
@@ -63,7 +63,7 @@ export function SettingsTab() {
|
||||
|
||||
const updateChannelMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
|
||||
services.push.updateChannel(id, data),
|
||||
PushService.updateChannel(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
@@ -75,7 +75,7 @@ export function SettingsTab() {
|
||||
})
|
||||
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (id: number) => services.push.deleteChannel(id),
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
toast.success("通道删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
@@ -237,7 +237,7 @@ export function SettingsTab() {
|
||||
try {
|
||||
setIsTestingChannel(true)
|
||||
toast.info("正在发送测试推送...")
|
||||
await services.push.testChannel({
|
||||
await PushService.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import type {SystemStatus} from "@/lib/services"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemStatus} from "@/lib/services/admin"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
@@ -40,7 +40,7 @@ export function SystemStatusManager() {
|
||||
const fetchStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setWavelet(true)
|
||||
try {
|
||||
const data = await services.admin.getSystemStatus()
|
||||
const data = await AdminService.getSystemStatus()
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
|
||||
|
||||
import {AdminService, TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
@@ -31,6 +33,8 @@ const TRIGGER_LABELS: Record<string, string> = {
|
||||
schedule: "定时",
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
@@ -51,108 +55,85 @@ function statusVariant(status: TaskExecutionStatus) {
|
||||
}
|
||||
|
||||
export function TaskExecutionsManager() {
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
const [executionsLoading, setExecutionsLoading] = useState(false)
|
||||
const [executionsError, setExecutionsError] = useState<Error | null>(null)
|
||||
const [executions, setExecutions] = useState<TaskExecution[]>([])
|
||||
const [executionsTotal, setExecutionsTotal] = useState(0)
|
||||
const queryClient = useQueryClient()
|
||||
const [executionsPage, setExecutionsPage] = useState(1)
|
||||
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
|
||||
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
|
||||
const [selectedExecution, setSelectedExecution] = useState<TaskExecution | null>(null)
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
|
||||
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
|
||||
const fetchTaskTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await AdminService.getTaskTypes()
|
||||
setTaskTypes(data)
|
||||
} catch {
|
||||
setTaskTypes([])
|
||||
}
|
||||
}, [])
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => AdminService.getTaskTypes(),
|
||||
})
|
||||
|
||||
const fetchTaskExecutions = useCallback(async (overrides?: {
|
||||
page?: number
|
||||
status?: TaskExecutionStatus | "all"
|
||||
taskType?: string
|
||||
}) => {
|
||||
try {
|
||||
setExecutionsLoading(true)
|
||||
setExecutionsError(null)
|
||||
const page = overrides?.page ?? executionsPage
|
||||
const status = overrides?.status ?? executionStatus
|
||||
const taskType = overrides?.taskType ?? executionTaskType
|
||||
const data = await AdminService.listTaskExecutions({
|
||||
page,
|
||||
page_size: 10,
|
||||
status: status === "all" ? undefined : status,
|
||||
task_type: taskType === "all" ? undefined : taskType,
|
||||
})
|
||||
setExecutions(data.items || [])
|
||||
setExecutionsTotal(data.total || 0)
|
||||
} catch (err) {
|
||||
setExecutionsError(err instanceof Error ? err : new Error("加载任务执行记录失败"))
|
||||
} finally {
|
||||
setExecutionsLoading(false)
|
||||
}
|
||||
}, [executionStatus, executionTaskType, executionsPage])
|
||||
const executionsQuery = useQuery({
|
||||
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
|
||||
queryFn: () => AdminService.listTaskExecutions({
|
||||
page: executionsPage,
|
||||
page_size: PAGE_SIZE,
|
||||
status: executionStatus === "all" ? undefined : executionStatus,
|
||||
task_type: executionTaskType === "all" ? undefined : executionTaskType,
|
||||
}),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["admin", "task-execution", selectedExecutionId],
|
||||
queryFn: () => AdminService.getTaskExecution(selectedExecutionId!),
|
||||
enabled: detailOpen && !!selectedExecutionId,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskExecutions()
|
||||
}, [fetchTaskExecutions])
|
||||
|
||||
useEffect(() => {
|
||||
setExecutionsPage(1)
|
||||
}, [executionStatus, executionTaskType])
|
||||
|
||||
const openExecutionDetail = async (execution: TaskExecution) => {
|
||||
setSelectedExecution(execution)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
setDetailLoading(true)
|
||||
const detail = await AdminService.getTaskExecution(execution.id)
|
||||
setSelectedExecution(detail)
|
||||
} catch (err) {
|
||||
toast.error("任务详情加载失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryExecution = async () => {
|
||||
if (!selectedExecution) return
|
||||
try {
|
||||
setRetrying(true)
|
||||
const taskID = await AdminService.retryTaskExecution(selectedExecution.id)
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: string) => AdminService.retryTaskExecution(id),
|
||||
onSuccess: (taskID) => {
|
||||
toast.success("任务已重新下发", {
|
||||
description: `新任务 ID:${ taskID }`,
|
||||
})
|
||||
await fetchTaskExecutions()
|
||||
} catch (err) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error("任务重试失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
description: err.message || "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
|
||||
const executions = executionsQuery.data?.items ?? []
|
||||
const executionsTotal = executionsQuery.data?.total ?? 0
|
||||
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
|
||||
const executionsError = executionsQuery.isError ? executionsQuery.error : null
|
||||
const selectedExecution = detailQuery.data ?? executionPreview
|
||||
const detailLoading = detailQuery.isFetching
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
|
||||
[executionsTotal],
|
||||
)
|
||||
|
||||
const openExecutionDetail = (execution: TaskExecution) => {
|
||||
setExecutionPreview(execution)
|
||||
setSelectedExecutionId(execution.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
|
||||
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
|
||||
setExecutionStatus(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setExecutionTaskType(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(value as TaskExecutionStatus | "all")}>
|
||||
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
|
||||
<SelectTrigger size="sm" className="w-[120px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
@@ -164,7 +145,7 @@ export function TaskExecutionsManager() {
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={executionTaskType} onValueChange={setExecutionTaskType}>
|
||||
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
|
||||
<SelectTrigger size="sm" className="w-[180px]">
|
||||
<SelectValue placeholder="任务类型" />
|
||||
</SelectTrigger>
|
||||
@@ -175,7 +156,7 @@ export function TaskExecutionsManager() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchTaskExecutions()} disabled={executionsLoading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
|
||||
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
@@ -184,7 +165,7 @@ export function TaskExecutionsManager() {
|
||||
|
||||
{executionsError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||
<ErrorInline error={executionsError} onRetry={fetchTaskExecutions} className="justify-center" />
|
||||
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : executionsLoading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
|
||||
@@ -375,13 +356,13 @@ export function TaskExecutionsManager() {
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t">
|
||||
<Button variant="outline" onClick={() => selectedExecution && openExecutionDetail(selectedExecution)} disabled={!selectedExecution || detailLoading}>
|
||||
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
|
||||
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新详情
|
||||
</Button>
|
||||
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
|
||||
<Button onClick={handleRetryExecution} disabled={retrying}>
|
||||
{retrying ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
|
||||
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
|
||||
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
|
||||
重试任务
|
||||
</Button>
|
||||
)}
|
||||
@@ -390,4 +371,4 @@ export function TaskExecutionsManager() {
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {Spinner} from "@/components/ui/spinner"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
|
||||
|
||||
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
|
||||
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services"
|
||||
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
||||
import type {AdminUser} from "@/lib/services"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {cn, formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {UserService} from "@/lib/services"
|
||||
import {UserService} from "@/lib/services/user"
|
||||
import type {CreateTokenResponse} from "@/lib/services/user"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} fro
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
|
||||
|
||||
const emptyForm: AuthSourceRequest = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {KeyRound, ShieldAlert, X} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {TemplatesManager} from "./templates"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {AuthService, ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import type {ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services/auth"
|
||||
import {UploadService} from "@/lib/services/upload/upload.service"
|
||||
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
|
||||
import {toast} from "sonner"
|
||||
|
||||
@@ -24,7 +24,7 @@ import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {AdminService} from "@/lib/services"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
|
||||
|
||||
@@ -242,6 +242,8 @@ export function UserFileManager() {
|
||||
<img
|
||||
src={getFileUrl(file.id, "medium") ?? undefined}
|
||||
alt={file.file_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
|
||||
onError={(e) => {
|
||||
;(e.currentTarget as HTMLImageElement).style.display = "none"
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useEffect} from 'react'
|
||||
import services from '@/lib/services'
|
||||
import {ConfigService} from '@/lib/services/config'
|
||||
import {usePathname} from 'next/navigation'
|
||||
|
||||
export function SiteTitleUpdater() {
|
||||
const pathname = usePathname()
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => services.config.getPublicConfig(),
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import * as React from "react"
|
||||
import {createContext, useCallback, useContext, useRef, useState} from "react"
|
||||
import {useQueryClient} from "@tanstack/react-query"
|
||||
|
||||
import type {SystemConfig, UpdateSystemConfigRequest} from "@/lib/services"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig, UpdateSystemConfigRequest} from "@/lib/services/admin"
|
||||
import {AdminService} from "@/lib/services/admin"
|
||||
import {handleContextError} from "@/lib/utils/error-handling"
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
setSystemConfigsLoading(true)
|
||||
setSystemConfigsError(null)
|
||||
const data = await services.admin.listSystemConfigs(type)
|
||||
const data = await AdminService.listSystemConfigs(type)
|
||||
|
||||
if (requestId !== systemRequestIdRef.current) {
|
||||
return
|
||||
@@ -71,7 +71,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
||||
/** 更新系统配置 */
|
||||
const updateSystemConfig = useCallback(async (key: string, data: UpdateSystemConfigRequest) => {
|
||||
try {
|
||||
await services.admin.updateSystemConfig(key, data)
|
||||
await AdminService.updateSystemConfig(key, data)
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] })
|
||||
await refetchSystemConfigs(lastConfigTypeRef.current)
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {AdminService, AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services"
|
||||
import {AdminService, AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services/admin"
|
||||
|
||||
/** 用户列表查询参数 */
|
||||
export interface UserQueryParams {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'
|
||||
|
||||
import services from '@/lib/services'
|
||||
import {AuthService} from '@/lib/services/auth'
|
||||
import {User} from '@/lib/services/auth/types'
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
const user = await services.auth.getUserInfo()
|
||||
const user = await AuthService.getUserInfo()
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
@@ -83,7 +83,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
/** 用户登出 */
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await services.auth.logout()
|
||||
await AuthService.logout()
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {ConfigService} from '@/lib/services'
|
||||
import {ConfigService} from '@/lib/services/config'
|
||||
|
||||
/**
|
||||
* 获取公共配置(使用 React Query 进行统一缓存与自动同步)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isCancelError } from '@/lib/services'
|
||||
import { isCancelError } from '@/lib/services/core'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user