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:
ryan
2026-06-17 12:12:47 +08:00
parent 9550fa6ff3
commit a44043262e
43 changed files with 752 additions and 695 deletions
+23 -17
View File
@@ -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>
)
}
+3 -311
View File
@@ -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>
)
}
+3 -48
View File
@@ -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>
)
}
+3 -112
View File
@@ -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 />
}
+1 -1
View File
@@ -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"
+3 -7
View File
@@ -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>
)}
+8 -7
View File
@@ -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 ||
+2 -2
View File
@@ -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)
+5 -4
View File
@@ -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,
})
+3 -3
View File
@@ -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 -4
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import {useQuery} from '@tanstack/react-query'
import {ConfigService} from '@/lib/services'
import {ConfigService} from '@/lib/services/config'
/**
* 获取公共配置(使用 React Query 进行统一缓存与自动同步)
+1 -1
View File
@@ -1,4 +1,4 @@
import { isCancelError } from '@/lib/services'
import { isCancelError } from '@/lib/services/core'
import { toast } from 'sonner'
/**