refactor(frontend): optimization

This commit is contained in:
ryan
2026-06-15 15:36:58 +08:00
parent 1425fd1dbb
commit 84ae4ec27e
34 changed files with 2952 additions and 3007 deletions
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/pkg/logger"
"go.uber.org/zap"
)
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/pkg/logger"
"go.uber.org/zap"
)
+17
View File
@@ -205,6 +205,23 @@ Handler 规范:
- 如果现有的变体不足以满足需求,请扩展 shadcn/ui 组件的变体,而不是硬编码一次性的颜色。
- 使用 Lucide 图标来满足常见的图标需求。将自定义图标作为命名导出放在 `frontend/components/icons/` 中。
页面标题栏规范 (新人开发与重构必读):
- **结构极简与高度专注**:标题栏应绝对干净,**禁止**在标题正下方放置任何描述性文本、小字副标题或段落(如 `<p>` 或 `CardDescription`)。这样能让用户迅速聚焦于业务主功能,避免低效的信息噪声。
- **容器与对齐机制**:
- 标题容器统一使用 `flex items-center gap-2`。如果右侧有操作按钮(如“新增”、“刷新”),请使用 `justify-between` 布局让操作区与标题双向分布。
- 为了确保所有页面在进入/切换时,顶部的呼吸感和视觉高度完全一致,页面最外层容器**必须**统一使用 `py-6 px-1` 或 `py-6` 进行上边距对齐。
- **无下边框**:标题栏下方**严禁**带有横线(禁用 `border-b` / `border-border`),让页面在纵向上保持连贯性与开阔感。
- **图标标准**:图标作为视觉辅助点缀,**必须**直接嵌套在标题容器中,直接使用 Lucide 图标组件,样式大小限制为 `size-5 text-primary`。**严禁**为图标包裹任何背景小卡片、圆角边框或额外的修饰容器。
- **标题文字标准**:标题文字使用且仅使用 `h1 className="text-2xl font-semibold tracking-tight"`。不要自行定义字号、字量(如使用 `font-bold`)或添加任何渐变色,保持整个系统的字形规范化。
- **Tabs 模块化与文件拆分规范**:凡是带有多个 Tab 页切换的复杂页面,**禁止**将所有 Tab 的渲染逻辑堆积在同一个主文件内。每个 Tab 的具体渲染内容必须单独拆分为独立的 React 组件文件(如 `tabs/events-tab.tsx`)。主页面文件应该仅用于导入子组件、注册 Tabs 触发器以及管理 Tab 的切换激活状态。这有利于防止单文件过大(避免单文件行数超过 600 行限制),并大幅度提高代码的可读性与编译维护效率。
- **扁平化结构与避免冗余中间件**:为了消除无意义的“中间代理文件”,所有作为路由物理入口的 Tabs 状态维护、骨架及外层布局代码,**必须**直接定义在 Next.js 的 `app/` 页面文件(即 `page.tsx`)中。禁止在 `page.tsx` 中仅写一个单纯的 `<AnotherComponent />` 转发,而在外部新建一个同名中转容器。
- **复杂度驱动的组件拆分规范**:组件的拆分不应局限于“跨页面复用”。当一个路由页面的复杂度变高时(如渲染逻辑膨胀、存在大型嵌套弹窗或多层状态管理,如单文件代码行数超过 600 行),必须主动将其拆分为子组件以维持单文件的高可读性与低耦合度。拆分时遵循就近原则:特定于该路由且不复用的子组件应放置在最邻近该路由的特征目录(Feature Folder,如 `components/` 局部文件夹)中;只有真正具备跨页面复用价值的通用业务/基础 UI 组件才应存放在全局 `components/` 共享目录下。
- **最佳实践标杆案例(数据管理 `/admin/database`)**:
该页面由于整合了“运行状态概览”、“物理表网格浏览器”、“磁盘缓存管理”和“SQL 交互控台”多个复杂大区块,重构前单文件接近 1000 行。
重构后,主页面 `page.tsx` 仅做高级页面骨架与排版排布,维护全局刷新机制与终端视图切换;而“数据表浏览器 (`table-browser.tsx`)”、“缓存管理 (`cache-manager.tsx`)”与“SQL 终端 (`sql-console.tsx`)”等独立高状态密度区块均被抽离为局部子组件,存放在 `frontend/app/(main)/admin/database/components/`。这保证了代码结构层次清晰、单文件小巧好维护。所有复杂页面的新开发和重构必须遵循此模式。
页面宽度:
- 页面根容器必须支持全宽。使用 `w-full`。
+309 -4
View File
@@ -1,8 +1,313 @@
"use client"
import {DatabaseManager} from "@/components/common/admin/database-manage"
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"
/* 数据库管理页面 */
export default function DatabasePage() {
return <DatabaseManager />
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, ",")
}
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>
)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {Code} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
// 引入模块化拆分后的 Tab 子组件
import {DashboardTab} from "./tabs/dashboard-tab"
import {TableTab} from "./tabs/table-tab"
import {ControlsTab} from "./tabs/controls-tab"
export default function HeaderDemoPage() {
const [activeTab, setActiveTab] = React.useState("dashboard")
return (
<div className="py-6 px-1 space-y-6">
{/* 1. 标准页面标题 */}
<div className="flex items-center gap-2">
<Code className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">开发规范示例</h1>
</div>
</div>
{/* 说明区域 */}
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Code className="size-4 text-primary" />
<CardTitle className="text-base font-semibold">规范说明</CardTitle>
</div>
<CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">核心规范细节与编写原则:</h3>
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li><strong>结构专注</strong>:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。</li>
<li><strong>容器与上边距对齐</strong>:使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-center gap-2</code> 作为标题行基础容器(不含下边框),外层统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6 px-1</code> (或 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6</code>) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。</li>
<li><strong>图标呈现</strong>:直接将 Lucide 图标组件嵌套于标题容器中,应用 <code className="bg-muted px-1 py-0.5 rounded font-mono">size-5 text-primary</code> 样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。</li>
<li><strong>文本样式标准</strong>:文本使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'h1 className="text-2xl font-semibold tracking-tight"'}</code>,禁用字重加粗(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">font-bold</code>)或任何渐变色,保证全站字体一致性。</li>
<li><strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab 页切换的页面,**禁止**将所有 Tab 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 Content 必须单独封装进全局 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/</code> 目录下专属文件夹中,主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。这有利于模块复用、编译优化以及提升单文件可读性。</li>
<li><strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs 切换,当一个页面为单页界面且代码行数超过 800 行时,必须将不同的物理块内容拆分为子组件。为保持路由树的清爽和逻辑纯粹,所有拆分出的非复用子组件也应统一存放在全局组件文件夹中(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/common/admin/</code>),而路由入口 <code className="bg-muted px-1 py-0.5 rounded font-mono">page.tsx</code> 仅用于定义整个页面的布局排版与全局框架。典型标杆案例参考“数据管理” (<code className="bg-muted px-1 py-0.5 rounded font-mono">/admin/database</code>) 的重构。</li>
</ul>
</div>
</CardContent>
</Card>
{/* Tabs 切换 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="dashboard" className="px-0 pb-2 text-xs font-semibold">
仪表盘与指标卡片
</TabsTrigger>
<TabsTrigger value="table" className="px-0 pb-2 text-xs font-semibold">
数据表格规范
</TabsTrigger>
<TabsTrigger value="controls" className="px-0 pb-2 text-xs font-semibold">
输入控件与表单
</TabsTrigger>
</TabsList>
<TabsContent value="dashboard" className="focus-visible:outline-none">
<DashboardTab />
</TabsContent>
<TabsContent value="table" className="focus-visible:outline-none">
<TableTab />
</TabsContent>
<TabsContent value="controls" className="focus-visible:outline-none">
<ControlsTab />
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,150 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {Code} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Label} from "@/components/ui/label"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Checkbox} from "@/components/ui/checkbox"
import {Button} from "@/components/ui/button"
export function ControlsTab() {
const [formData, setFormData] = React.useState({
name: "",
category: "database",
allowNotify: true,
channel: "all",
})
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 控件展示 */}
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">表单与输入框交互演示</CardTitle>
<CardDescription>包含按钮变体、下拉选择、多选框及基础输入框的标准样式</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 输入框 */}
<div className="space-y-1.5">
<Label htmlFor="task-name" className="text-xs font-medium text-foreground">任务名称</Label>
<Input
id="task-name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="输入要创建的任务描述..."
className="h-8 text-xs shadow-none bg-background focus-visible:ring-1"
/>
<p className="text-[10px] text-muted-foreground">任务名称将用于后台执行记录的可读性显示</p>
</div>
{/* 下拉框 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">任务分类</Label>
<Select
value={formData.category}
onValueChange={(val) => setFormData(prev => ({ ...prev, category: val }))}
>
<SelectTrigger className="h-8 text-xs shadow-none bg-background focus:ring-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="database">数据库运维</SelectItem>
<SelectItem value="sync">数据批量同步</SelectItem>
<SelectItem value="cleanup">过期磁盘清理</SelectItem>
</SelectContent>
</Select>
</div>
{/* 多选框 Checkbox */}
<div className="flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg">
<Checkbox
id="allow-notify"
checked={formData.allowNotify}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allowNotify: checked === true }))}
className="mt-0.5"
/>
<div className="grid gap-1">
<label
htmlFor="allow-notify"
className="text-xs font-medium text-foreground cursor-pointer select-none leading-none"
>
允许触发异步通知推送
</label>
<p className="text-[10px] text-muted-foreground leading-normal">
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
</p>
</div>
</div>
{/* 按钮变体 */}
<div className="pt-2">
<div className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">按钮样式对齐:</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" className="h-8 text-xs shadow-none">
主要操作 (Primary)
</Button>
<Button variant="secondary" size="sm" className="h-8 text-xs shadow-none">
次要操作 (Secondary)
</Button>
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
辅助线框 (Outline)
</Button>
<Button variant="ghost" size="sm" className="h-8 text-xs hover:bg-muted">
幽灵按钮 (Ghost)
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 控件设计规范说明 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
表单设计规范
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-xs text-muted-foreground">
<div className="space-y-2 leading-relaxed">
<p className="font-semibold text-foreground text-[11px]">1. 统一控件高度与投影:</p>
<p>
输入框 (<code className="bg-muted px-1 py-0.5 rounded font-mono">Input</code>) 与下拉选择器 (<code className="bg-muted px-1 py-0.5 rounded font-mono">SelectTrigger</code>) 的高度应统一限制为小号高度 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8</code>,字体大小使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs</code>,并使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">shadow-none</code> 去除原生的重叠阴影。
</p>
<p className="font-semibold text-foreground text-[11px] pt-1">2. Checkbox 与文本的对齐:</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-start gap-2.5</code> 排列。Checkbox 组件本身添加 <code className="bg-muted px-1 py-0.5 rounded font-mono">mt-0.5</code>,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
</p>
<p className="font-semibold text-foreground text-[11px] pt-1">3. 按钮使用策略:</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'variant="secondary"'}</code> 按钮,小号高度统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8 text-xs</code>。线框按钮必须带有 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 类。
</p>
</div>
<div className="bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto">
<div>{`// 1. Label 与 Input 对齐`}</div>
<div>{`<div className="space-y-1.5">
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
<Input id="id" className="h-8 text-xs shadow-none" />
</div>`}</div>
<div className="pt-2">{`// 2. Checkbox 顶对齐`}</div>
<div>{`<div className="flex items-start gap-2.5">
<Checkbox id="c" className="mt-0.5" />
<div className="grid gap-1">
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
</div>
</div>`}</div>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,98 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {Code, Cpu, Database, HardDrive} from "lucide-react"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
import {Progress} from "@/components/ui/progress"
export function DashboardTab() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* CPU */}
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">容器 CPU 使用率</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<Progress value={42.8} className="h-1.5" />
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<span className="size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse" />
容器运行正常,负载平稳
</p>
</CardContent>
</Card>
{/* Storage */}
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">存储已用容量</span>
<HardDrive className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">72.4 GB <span className="text-xs text-muted-foreground">/ 100 GB</span></div>
<Progress value={72.4} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">
数据已同步远端 S3 存储桶,支持自动过期
</p>
</CardContent>
</Card>
{/* DB Overview */}
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">物理数据库</span>
<Database className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">PostgreSQL <span className="text-xs font-normal text-muted-foreground">(v16.2)</span></div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div>活跃连接: <span className="font-semibold text-foreground">12</span></div>
<div>总表数量: <span className="font-semibold text-foreground">34</span></div>
</div>
<p className="text-[10px] text-muted-foreground">
链接池自动维护中,最大连接限制为 100
</p>
</CardContent>
</Card>
</div>
{/* 指标卡片规范与代码 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
指标卡片 & 仪表盘设计规范
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li>指标卡片统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 作为边框和投影。</li>
<li>头部使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex flex-row items-center justify-between pb-2</code> 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。</li>
<li>数值强调使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-2xl font-semibold tracking-tight</code> 展示。</li>
<li>进度条使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-1.5</code> 细条高度,不喧宾夺主。</li>
<li>底部的次要说明信息必须统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-[10px] text-muted-foreground</code> 等辅助类。</li>
</ul>
<pre className="text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed">
{`<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">指标标题</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<Progress value={42.8} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">状态说明小字</p>
</CardContent>
</Card>`}
</pre>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,142 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {Code, Edit, ListFilter, Plus, Search, Trash2} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Badge} from "@/components/ui/badge"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
export function TableTab() {
const mockTableData = [
{ id: "TX-1002", name: "用户信息批量同步导出", type: "数据同步", status: "success", duration: "1.2s", time: "2026-06-15 14:22" },
{ id: "TX-1003", name: "系统数据库每周自动备份归档", type: "数据库运维", status: "running", duration: "在执行中", time: "2026-06-15 15:00" },
{ id: "TX-1004", name: "远端S3过期缓存对象自动清理任务", type: "磁盘清理", status: "failed", duration: "0.4s", time: "2026-06-15 10:05" },
]
return (
<div className="space-y-6">
{/* 表格控制工具栏 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-2.5 top-2.5 size-3 text-muted-foreground" />
<Input placeholder="输入任务名称搜索..." className="h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background" />
</div>
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
<ListFilter className="size-3 mr-1" />
过滤
</Button>
</div>
<Button size="sm" className="h-8 text-xs shadow-none">
<Plus className="size-3.5 mr-1" />
新建任务
</Button>
</div>
{/* 数据表格本身 */}
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="w-[100px] text-xs font-semibold">任务编号</TableHead>
<TableHead className="text-xs font-semibold">执行任务名称</TableHead>
<TableHead className="w-[120px] text-xs font-semibold">任务类型</TableHead>
<TableHead className="w-[100px] text-xs font-semibold">状态</TableHead>
<TableHead className="w-[100px] text-xs font-semibold">执行耗时</TableHead>
<TableHead className="w-[150px] text-xs font-semibold">触发时间</TableHead>
<TableHead className="w-[80px] text-xs font-semibold text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockTableData.map((row) => (
<TableRow key={row.id} className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs font-semibold">{row.id}</TableCell>
<TableCell className="text-xs font-medium text-foreground">{row.name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.type}</TableCell>
<TableCell>
{row.status === "success" && (
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium">
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
)}
{row.status === "running" && (
<Badge variant="outline" className="text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium">
<span className="size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse" />
运行中
</Badge>
)}
{row.status === "failed" && (
<Badge variant="outline" className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium">
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
失败
</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">{row.duration}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.time}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-muted text-muted-foreground">
<Edit className="size-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-destructive/10 text-destructive">
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 表格规范指引 */}
<Card className="border-dashed shadow-none bg-muted/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Code className="size-4 text-primary" />
表格设计规范与代码模板
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
<li>表格容器必须统一使用带有虚线边框的容器,即类名:<code className="bg-muted px-1 py-0.5 rounded font-mono">border border-dashed shadow-none rounded-lg overflow-hidden bg-background</code>。</li>
<li>表头背景设为 <code className="bg-muted px-1 py-0.5 rounded font-mono">bg-muted/40</code>。表头表项字号统一为小字粗体 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs font-semibold</code>。</li>
<li>单元格行与列之间使用虚线分割(<code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed</code>)。表格行悬浮交互采用温和过渡色 <code className="bg-muted px-1 py-0.5 rounded font-mono">hover:bg-muted/10 transition-colors</code>。</li>
<li>编码、主键或唯一 ID 类字段一律采用等宽字体呈现:<code className="bg-muted px-1 py-0.5 rounded font-mono">font-mono text-xs</code>。</li>
<li>状态标签(Badge)尽量圆角化(<code className="bg-muted px-1 py-0.5 rounded font-mono">rounded-full py-0 px-2</code>),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。</li>
</ul>
<pre className="text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed">
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">编号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs">ID-1001</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>`}
</pre>
</CardContent>
</Card>
</div>
)
}
+56 -2
View File
@@ -1,5 +1,59 @@
import {FilesMain} from "@/components/common/admin/files"
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {FolderOpen} from "lucide-react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {FileStats} from "@/components/common/admin/file-stats"
import {FileList} from "@/components/common/admin/file-list"
import {StorageConfigTab} from "@/components/common/admin/storage-config-tab"
export default function FilesPage() {
return <FilesMain />
const [activeTab, setActiveTab] = React.useState("stats")
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
>
{/* 顶部标题区 */}
<div className="flex items-center gap-2">
<FolderOpen className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">存储管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="stats" className="px-0 pb-2 text-xs font-semibold">
文件存储信息
</TabsTrigger>
<TabsTrigger value="list" className="px-0 pb-2 text-xs font-semibold">
文件列表
</TabsTrigger>
<TabsTrigger value="storage" className="px-0 pb-2 text-xs font-semibold">
存储配置
</TabsTrigger>
</TabsList>
{/* ──────── TAB 1: 统计看板 ──────── */}
<TabsContent value="stats" className="outline-hidden">
<FileStats />
</TabsContent>
{/* ──────── TAB 2: 文件列表 ──────── */}
<TabsContent value="list" className="outline-hidden">
<FileList />
</TabsContent>
<TabsContent value="storage" className="outline-hidden">
<StorageConfigTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
+61 -3
View File
@@ -1,8 +1,66 @@
/*
Copyright 2026 Arctel.net
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
import {SystemLogs} from "@/components/common/admin/system-logs"
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"
/* 系统日志页面 */
export default function LogsPage() {
return <SystemLogs />
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>
)
}
+58 -7
View File
@@ -1,14 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import type {Metadata} from "next";
import {PushNotificationManager} from "@/components/common/admin/push-notification";
"use client"
export const metadata: Metadata = {
title: "通知推送 - Wavelet Admin",
description: "系统通知多渠道推送与事件管理控制台",
};
import * as React from "react"
import {motion} from "motion/react"
import {Bell, History, Layers, Settings} from "lucide-react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {EventsTab} from "@/components/common/admin/push/events-tab"
import {HistoriesTab} from "@/components/common/admin/push/histories-tab"
import {SettingsTab} from "@/components/common/admin/push/settings-tab"
export default function PushAdminPage() {
return <PushNotificationManager />;
const [activeTab, setActiveTab] = React.useState("events")
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="w-full py-6 space-y-6"
>
<div className="flex items-center gap-2">
<Bell className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8">
<TabsTrigger value="events" className="px-0 pb-2 text-xs font-semibold">
<Layers className="size-3.5 mr-1" />
事件管理
</TabsTrigger>
<TabsTrigger value="histories" className="px-0 pb-2 text-xs font-semibold">
<History className="size-3.5 mr-1" />
通知历史
</TabsTrigger>
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
<Settings className="size-3.5 mr-1" />
通道管理与设置
</TabsTrigger>
</TabsList>
{/* ==================== 1. 事件管理 TAB ==================== */}
<TabsContent value="events" className="focus-visible:outline-none">
<EventsTab />
</TabsContent>
{/* ==================== 2. 通知历史 TAB ==================== */}
<TabsContent value="histories" className="focus-visible:outline-none">
<HistoriesTab />
</TabsContent>
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
<TabsContent value="settings" className="focus-visible:outline-none">
<SettingsTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
+113 -4
View File
@@ -1,8 +1,117 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import {SystemSettingsMain} from "@/components/common/settings/system-settings"
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"
/* 系统设置页面 */
export default function AdminSettingsPage() {
return <SystemSettingsMain />
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
}, {})
}
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>
)
}
+237 -4
View File
@@ -1,9 +1,243 @@
"use client"
import {SystemConfigs} from "@/components/common/admin/system"
import {AdminProvider} from "@/contexts/admin-context"
import * as React from "react"
import {Input} from "@/components/ui/input"
import {Switch} from "@/components/ui/switch"
import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel"
import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {ShieldCheck} from "lucide-react"
import {formatDateTime} from "@/lib/utils"
import type {SystemConfig} from "@/lib/services"
import {AdminProvider, useAdmin} from "@/contexts/admin-context"
/**
* 系统配置
* 显示系统配置的详细信息和编辑面板
*
* @example
* ```tsx
* <SystemConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {SystemConfig} config - 系统配置
* @param {Partial<SystemConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 系统配置详情面板组件
*/
function SystemConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-1 gap-0">
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
}}
/>
) : (
<Input
type={
config?.key && (
config.key.endsWith('_limit') ||
config.key.endsWith('_minutes') ||
config.key.endsWith('_days') ||
config.key.endsWith('_seconds') ||
config.key.endsWith('_hours') ||
config.key.endsWith('_ttl') ||
config.key.endsWith('_max') ||
config.key.endsWith('_min') ||
config.key.includes('max_') ||
config.key.includes('min_') ||
config.key.includes('limit') ||
config.key.includes('count')
) ? "number" : "text"
}
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('value', '')
return
}
onEditDataChange('value', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
style={{
MozAppearance: 'textfield'
}}
/>
)}
</div>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
<div className="flex gap-1 w-[90%]">
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
/>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">公共可见</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
onCheckedChange={(checked) => {
onEditDataChange('visibility', checked ? 1 : 0)
}}
/>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
config?.type === 'system'
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}>
{config?.type === 'system' ? '系统配置' : '业务配置'}
</span>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 系统配置管理组件
*
* @example
* ```tsx
* <SystemConfigs />
* ```
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const {
systemConfigs: configs,
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig
} = useAdmin()
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
React.useEffect(() => {
refetchSystemConfigs(activeTab)
}, [activeTab, refetchSystemConfigs])
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
visibility: config.visibility,
description: config.description
})
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
visibility: editData.visibility ?? config.visibility,
description: editData.description ?? config.description
})
}
return (
<ManagePage<SystemConfig>
title="系统配置"
icon={ShieldCheck}
data={configs}
loading={loading}
error={error}
onReload={() => refetchSystemConfigs(activeTab)}
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
headerExtra={
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'system' | 'business')} className="w-[180px]">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="business" className="text-[11px] h-7">业务配置</TabsTrigger>
<TabsTrigger value="system" className="text-[11px] h-7">系统配置</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "公共可见", cell: (item) => <span>{item.visibility === 1 ? "可见" : "不可见"}</span>, width: "80px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
/* 系统配置页面 */
export default function SystemConfigPage() {
return (
<AdminProvider>
@@ -11,4 +245,3 @@ export default function SystemConfigPage() {
</AdminProvider>
)
}
+14 -13
View File
@@ -7,7 +7,7 @@ import {TaskManager} from "@/components/common/admin/task-manager"
import {TaskSchedulesManager} from "@/components/common/admin/task-schedules"
import {TaskExecutionsManager} from "@/components/common/admin/task-executions"
import {Spinner} from "@/components/ui/spinner"
import {Activity} from "lucide-react"
import {Layers} from "lucide-react"
function TasksPageContent() {
const searchParams = useSearchParams()
@@ -19,23 +19,24 @@ function TasksPageContent() {
}
return (
<div className="py-6 space-y-6">
<div className="flex items-center gap-3 pb-2">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<Activity className="size-5" />
</div>
<div className="flex items-center gap-2">
<Layers className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-bold tracking-tight">任务管理</h1>
<p className="text-xs text-muted-foreground mt-0.5">
下发异步镜像与同步导出任务,管理后台执行计划及调度日志。
</p>
<h1 className="text-2xl font-semibold tracking-tight">任务管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList variant="line">
<TabsTrigger value="tasks">任务管理</TabsTrigger>
<TabsTrigger value="schedules">定时任务</TabsTrigger>
<TabsTrigger value="executions">任务日志</TabsTrigger>
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsTrigger value="tasks" className="px-0 pb-2 text-xs font-semibold">
任务管理
</TabsTrigger>
<TabsTrigger value="schedules" className="px-0 pb-2 text-xs font-semibold">
定时任务
</TabsTrigger>
<TabsTrigger value="executions" className="px-0 pb-2 text-xs font-semibold">
任务日志
</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-4 outline-none">
<TaskManager />
+264 -3
View File
@@ -1,8 +1,269 @@
"use client"
import { UsersManager } from "@/components/common/admin/users"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import {AdminUser} from "@/lib/services"
import {formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "@/components/common/admin/create-user-modal"
import {UserFilterBar} from "@/components/common/admin/user-filter-bar"
import {UserDetailSheet} from "@/components/common/admin/user-detail-sheet"
/* 用户管理页面 */
export default function UsersPage() {
return <UsersManager />
const {
users,
loading,
error,
fetchUsers,
getUserDetail,
updateUserStatus,
deleteUser
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user)
if (selectedUser?.id === user.id) {
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
}
}
const handleShowDetail = async (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
setDetailLoading(true)
try {
const detail = await getUserDetail(user.id)
setSelectedUser(detail)
} catch {
setSelectedUser(user)
} finally {
setDetailLoading(false)
}
}
const handleDeleteUser = async () => {
if (!deleteTarget) return
setDeleteLoading(true)
try {
await deleteUser(deleteTarget)
if (selectedUser?.id === deleteTarget.id) {
setDetailOpen(false)
setSelectedUser(null)
}
setDeleteTarget(null)
} finally {
setDeleteLoading(false)
}
}
return (
<div className="py-6 space-y-4">
{/* 顶部标题栏 */}
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-2">
<UserRound className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">用户管理</h1>
</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增用户
</Button>
</div>
{/* 筛选与分页栏 (已拆分为独立自治组件) */}
<UserFilterBar />
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
</TooltipContent>
</Tooltip>
</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)}>
<Eye className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
查看详情
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!user.is_admin && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除用户
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 用户详情侧拉抽屉 (已拆分为独立子组件) */}
<UserDetailSheet
selectedUser={selectedUser}
isOpen={detailOpen}
onOpenChange={setDetailOpen}
detailLoading={detailLoading}
onStatusToggle={handleStatusToggle}
onDeleteTarget={setDeleteTarget}
/>
{/* 删除确认警告弹窗 */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 新建用户模态弹窗 */}
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
}
-723
View File
@@ -1,723 +0,0 @@
"use client"
import * as React from "react"
import {useMemo, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Progress} from "@/components/ui/progress"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {Activity, ArrowRight, Check, Code2, Coins, Copy, Gift, Info, Layers, Shield, User} from "lucide-react"
import {Area, AreaChart, ResponsiveContainer, Tooltip as RechartsTooltip, XAxis} from "recharts"
import {toast} from "sonner"
// ==========================================
// 1. 卡片类组件 (Cards)
// ==========================================
function FeatureCard({
title,
description,
linkText,
href
}: {
title: string
description: string
linkText: string
href?: string
}) {
return (
<Card className="bg-card border border-dashed shadow-none hover:shadow-sm transition-all duration-300">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardDescription className="text-xs text-muted-foreground leading-normal">
{description}
</CardDescription>
<Button
variant="link"
className="p-0 h-auto text-xs text-indigo-500 font-medium hover:text-indigo-600 flex items-center gap-1"
onClick={() => {
if (href) {
toast.success(`模拟跳转到: ${href}`)
} else {
toast.info("此按钮为静态演示,无实际链接")
}
}}
>
{linkText} <ArrowRight className="size-3" />
</Button>
</CardContent>
</Card>
)
}
function ReceiveBanner() {
const [isVisible, setIsVisible] = useState(true)
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden mb-6"
>
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative">
<div className="max-w-xl space-y-3">
<h3 className="text-lg font-bold text-indigo-600 dark:text-indigo-400">获得积分收益</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。集成方式多样,满足各种场景需求。
</p>
<div className="flex gap-2.5">
<Button
size="sm"
className="bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm"
onClick={() => toast.success("正在进入积分探索模块...")}
>
开始使用
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setIsVisible(false)}
>
隐藏提示
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// ==========================================
// 2. 仪表盘组件 (Dashboards)
// ==========================================
const MOCK_CHART_DATA = [
{ date: "06-01", total: 12000, income: 400, expense: 200 },
{ date: "06-02", total: 13500, income: 1500, expense: 300 },
{ date: "06-03", total: 12800, income: 300, expense: 1000 },
{ date: "06-04", total: 15000, income: 2500, expense: 300 },
{ date: "06-05", total: 14200, income: 200, expense: 1000 },
{ date: "06-06", total: 16800, income: 3000, expense: 400 },
{ date: "06-07", total: 17500, income: 1200, expense: 500 },
]
function MockTrendChart() {
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">积分变动趋势 (静态模拟)</CardTitle>
</CardHeader>
<div className="w-full h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={MOCK_CHART_DATA} margin={{ left: -10, right: 10, top: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(99, 102, 241)" stopOpacity={0.2} />
<stop offset="95%" stopColor="rgb(99, 102, 241)" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false} />
<RechartsTooltip
contentStyle={{ background: "rgba(15, 23, 42, 0.9)", border: "none", borderRadius: "8px", color: "#fff" }}
labelStyle={{ fontWeight: "bold", fontSize: "11px", marginBottom: "4px" }}
itemStyle={{ fontSize: "11px", padding: "2px 0" }}
/>
<Area
name="总积分"
dataKey="total"
type="monotone"
fill="url(#colorTotal)"
stroke="rgb(99, 102, 241)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</Card>
)
}
function MockIncomeStats() {
const stats = [
{ date: "06-07", amount: 1200, percentage: 80 },
{ date: "06-06", amount: 3000, percentage: 100 },
{ date: "06-05", amount: 200, percentage: 15 },
{ date: "06-04", amount: 2500, percentage: 90 },
{ date: "06-03", amount: 300, percentage: 20 },
{ date: "06-02", amount: 1500, percentage: 65 },
{ date: "06-01", amount: 400, percentage: 25 },
]
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">7天收入统计 (静态模拟)</CardTitle>
<div className="text-lg font-bold mt-1">LDC 9,100.00</div>
</div>
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2" onClick={() => toast.success("正在刷新统计数据...")}>
刷新
</Button>
</CardHeader>
<div className="space-y-2">
{stats.map((s) => (
<div key={s.date} className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">{s.date}</span>
<span className="text-emerald-500 font-semibold">+{s.amount.toFixed(2)}</span>
</div>
<Progress value={s.percentage} className="h-1.5 [&>[data-slot=progress-indicator]]:bg-emerald-500" />
</div>
))}
</div>
</Card>
)
}
function MockTopCustomers() {
const customers = [
{ rank: 1, name: "dev_user", count: 42, amount: 28500 },
{ rank: 2, name: "linux_do_fan", count: 28, amount: 19800 },
{ rank: 3, name: "gorm_expert", count: 19, amount: 12400 },
{ rank: 4, name: "nextjs_coder", count: 12, amount: 8500 },
{ rank: 5, name: "redis_helper", count: 8, amount: 5000 },
]
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Top 客户统计 (静态模拟)</CardTitle>
</CardHeader>
<div className="space-y-3">
{customers.map((c) => (
<div key={c.rank} className="space-y-1">
<div className="flex justify-between items-center text-xs">
<div className="flex items-center gap-2">
<span className="font-bold text-muted-foreground/80 w-4">#{c.rank}</span>
<span className="font-medium">{c.name}</span>
</div>
<div className="font-semibold text-right">
{c.amount.toLocaleString()} LDC
</div>
</div>
<div className="flex items-center gap-2">
<Progress value={(c.amount / 28500) * 100} className="h-1.5" />
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-right">{c.count}单</span>
</div>
</div>
))}
</div>
</Card>
)
}
// ==========================================
// 3. 数据表格组件 (Tables)
// ==========================================
interface MockOrder {
id: string
order_name: string
amount: number
type: 'payment' | 'transfer' | 'community' | 'red_envelope'
status: 'success' | 'pending' | 'expired' | 'disputing' | 'refund'
order_no: string
created_at: string
}
const MOCK_ORDERS: MockOrder[] = [
{ id: "1", order_name: "API 接口充值积分", amount: 1000, type: "payment", status: "success", order_no: "ORD202606070001", created_at: "2026-06-07 14:20:15" },
{ id: "2", order_name: "退款原路退回", amount: 500, type: "payment", status: "refund", order_no: "ORD202606070002", created_at: "2026-06-07 12:05:44" },
{ id: "3", order_name: "拼手气红包分配", amount: 200, type: "red_envelope", status: "success", order_no: "ORD202606060012", created_at: "2026-06-06 18:30:00" },
{ id: "4", order_name: "在线流转纠纷处理", amount: 1500, type: "transfer", status: "disputing", order_no: "ORD202606050025", created_at: "2026-06-05 09:12:10" },
{ id: "5", order_name: "社区绑定自动转入", amount: 350, type: "community", status: "pending", order_no: "ORD202606040081", created_at: "2026-06-04 23:45:19" },
{ id: "6", order_name: "红包超时未领过期退回", amount: 100, type: "red_envelope", status: "expired", order_no: "ORD202606030009", created_at: "2026-06-03 15:00:00" },
]
function MockTransactionTable() {
const getStatusBadge = (status: MockOrder["status"]) => {
const map = {
success: { text: "交易成功", class: "bg-green-500/10 text-green-600 border-green-500/20" },
pending: { text: "等待付款", class: "bg-amber-500/10 text-amber-600 border-amber-500/20" },
expired: { text: "订单过期", class: "bg-slate-500/10 text-slate-500 border-slate-500/20" },
disputing: { text: "争议中", class: "bg-rose-500/10 text-rose-600 border-rose-500/20" },
refund: { text: "交易退款", class: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
}
return (
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 font-medium ${map[status].class}`}>
{map[status].text}
</Badge>
)
}
const getTypeBadge = (type: MockOrder["type"]) => {
const map = {
payment: { text: "消耗", class: "bg-orange-500/10 text-orange-600" },
transfer: { text: "流转", class: "bg-blue-500/10 text-blue-600" },
community: { text: "社区", class: "bg-purple-500/10 text-purple-600" },
red_envelope: { text: "红包", class: "bg-red-500/10 text-red-600" },
}
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${map[type].class}`}>
{map[type].text}
</span>
)
}
return (
<Card className="border border-dashed shadow-none rounded-lg overflow-hidden">
<div className="w-full overflow-x-auto">
<table className="w-full caption-bottom text-xs">
<thead>
<tr className="border-b border-dashed bg-muted/20">
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-[180px]">名称</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[80px]">类型</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[90px]">额度</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[90px]">状态</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">流水编号</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-[150px]">交易时间</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[80px]">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed">
{MOCK_ORDERS.map((order) => (
<tr key={order.id} className="hover:bg-muted/30 transition-colors">
<td className="p-4 font-medium">{order.order_name}</td>
<td className="p-4 text-center">{getTypeBadge(order.type)}</td>
<td className="p-4 text-center font-semibold text-slate-800 dark:text-slate-200">
{order.amount.toFixed(2)}
</td>
<td className="p-4 text-center">{getStatusBadge(order.status)}</td>
<td className="p-4 font-mono text-muted-foreground">{order.order_no}</td>
<td className="p-4 text-muted-foreground">{order.created_at}</td>
<td className="p-4 text-center">
<Button
variant="link"
className="p-0 h-auto text-[11px] text-indigo-500"
onClick={() => toast.success(`查看详情: ${order.order_no}`)}
>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)
}
// ==========================================
// 4. Tab 导航组件 (Tabs)
// ==========================================
function MockTradeTabs() {
const [activeTab, setActiveTab] = useState<string>("receive")
const tabContent = {
receive: {
title: "积分收益 (Receive)",
description: "本模块汇集用户所有的进账记录。可通过 API、活动赚取等形式增加积分值。",
icon: Coins,
color: "text-emerald-500",
bgColor: "bg-emerald-500/10",
},
payment: {
title: "积分消耗 (Payment)",
description: "本模块汇总用户的出账流水。支持兑换、系统扣减与在线付款场景的费用记账。",
icon: Gift,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
community: {
title: "社区划转 (Community)",
description: "对接 Discourse 外部论坛同步时自动调度的积分余额转换事件。",
icon: Shield,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
online: {
title: "在线流转 (Online)",
description: "指代用户通过付款链接或集市网关主动进行的收单支付流转活动记录。",
icon: Activity,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
all: {
title: "所有活动 (All Activities)",
description: "汇总上述所有分类的完整历史日志,支持按分类检索和导出报表。",
icon: Layers,
color: "text-slate-500",
bgColor: "bg-slate-500/10",
},
}
const activeInfo = tabContent[activeTab as keyof typeof tabContent] || tabContent.receive
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
<TabsTrigger
value="receive"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
积分收益
</TabsTrigger>
<TabsTrigger
value="payment"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
积分消耗
</TabsTrigger>
<TabsTrigger
value="community"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
社区划转
</TabsTrigger>
<TabsTrigger
value="online"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
在线流转
</TabsTrigger>
<TabsTrigger
value="all"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
所有活动
</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="pt-4 animate-in fade-in duration-200">
<div className="flex gap-4 items-start p-4 border border-dashed rounded-lg bg-muted/20">
<div className={`size-10 rounded-lg flex items-center justify-center shrink-0 ${activeInfo.bgColor} ${activeInfo.color}`}>
<activeInfo.icon className="size-5" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-semibold">{activeInfo.title}</h4>
<p className="text-xs text-muted-foreground leading-normal">{activeInfo.description}</p>
</div>
</div>
</TabsContent>
</Tabs>
</Card>
)
}
// ==========================================
// 5. 代码片段复制辅助组件
// ==========================================
function CodeShowcase({ code, title }: { code: string; title: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("代码片段已复制到剪贴板")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("复制失败")
}
}
return (
<Card className="border border-dashed shadow-none rounded-lg bg-slate-950 text-slate-200 overflow-hidden">
<CardHeader className="py-2.5 px-4 bg-slate-900 border-b border-slate-800 flex flex-row items-center justify-between">
<div className="text-[11px] font-mono text-slate-400 flex items-center gap-1.5">
<Code2 className="size-3.5" />
{title}
</div>
<Button variant="ghost" size="icon" className="size-6 text-slate-400 hover:text-white" onClick={handleCopy}>
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
</Button>
</CardHeader>
<pre className="p-4 font-mono text-[10px] overflow-x-auto leading-relaxed max-h-[300px] select-all [&::-webkit-scrollbar]:hidden">
<code>{code}</code>
</pre>
</Card>
)
}
// ==========================================
// 主组件 (Main Showcase)
// ==========================================
export default function ComponentLibraryPage() {
const [activeCategory, setActiveCategory] = useState<string>("cards")
const categories = [
{ id: "cards", title: "卡片组件 (Cards)", desc: "页面核心功能聚合引导卡片", icon: User },
{ id: "dashboards", title: "大盘统计 (Dashboards)", desc: "交易波动与客户数据展示图表", icon: Activity },
{ id: "tables", title: "数据表格 (Tables)", desc: "支持多类型与状态的明细流水表", icon: Layers },
{ id: "tabs", title: "多标签页 (Tabs)", desc: "分类管理多页签内容切换", icon: Info },
]
const codeSnippets = useMemo(() => {
return {
cards: `// FeatureCard: 三栏网格功能展示卡片
interface FeatureCardProps {
title: string;
description: string;
linkText: string;
href?: string;
}
export function FeatureCard({ title, description, linkText, href }: FeatureCardProps) {
return (
<Card className="bg-card border border-dashed shadow-none hover:shadow-sm transition-all duration-300">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardDescription className="text-xs text-muted-foreground leading-normal">
{description}
</CardDescription>
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500" asChild>
<a href={href || "#"}>{linkText} &rarr;</a>
</Button>
</CardContent>
</Card>
);
}`,
dashboards: `// 7天收入统计: 进度条数据统计卡片
interface IncomeStatItem {
date: string;
amount: number;
percentage: number;
}
export function IncomeStatsCard({ stats }: { stats: IncomeStatItem[] }) {
return (
<Card className="border border-dashed shadow-none p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs text-muted-foreground uppercase">7天收入统计</CardTitle>
</CardHeader>
<div className="space-y-2">
{stats.map(s => (
<div key={s.date} className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">{s.date}</span>
<span className="text-emerald-500 font-semibold">+{s.amount.toFixed(2)}</span>
</div>
<Progress value={s.percentage} className="h-1.5" />
</div>
))}
</div>
</Card>
);
}`,
tables: `// 活动记录表格: 数据明细展现
interface MockOrder {
id: string;
order_name: string;
amount: number;
type: 'payment' | 'transfer' | 'community' | 'red_envelope';
status: 'success' | 'pending' | 'expired' | 'disputing' | 'refund';
order_no: string;
created_at: string;
}
export function TransactionTable({ orders }: { orders: MockOrder[] }) {
return (
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/20">
<th className="p-2 text-left font-medium">名称</th>
<th className="p-2 text-center font-medium">额度</th>
<th className="p-2 text-center font-medium">状态</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id} className="hover:bg-muted/30">
<td className="p-2 font-medium">{order.order_name}</td>
<td className="p-2 text-center font-semibold">{order.amount.toFixed(2)}</td>
<td className="p-2 text-center">{order.status}</td>
</tr>
))}
</tbody>
</table>
);
}`,
tabs: `// Tab 触发器及多模块切换导航
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export function MockTradeTabs() {
return (
<Tabs defaultValue="receive" className="w-full">
<TabsList className="flex gap-4 bg-transparent border-b">
<TabsTrigger value="receive" className="px-0 pb-2 text-xs font-bold">
积分收益
</TabsTrigger>
<TabsTrigger value="payment" className="px-0 pb-2 text-xs font-bold">
积分消耗
</TabsTrigger>
</TabsList>
<TabsContent value="receive">积分收益模块内容...</TabsContent>
<TabsContent value="payment">积分消耗模块内容...</TabsContent>
</Tabs>
);
}`,
}
}, [])
return (
<div className="py-6 space-y-6 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">静态组件库 (Component Library)</h1>
<p className="text-xs text-muted-foreground mt-1 leading-normal">
展示项目重构中提取的有价值静态组件资源,提供可视化的静态演示与代码片段,便于平台后续的二次开发集成。
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧导航 */}
<div className="lg:col-span-1 space-y-2">
{categories.map((cat) => (
<button
key={cat.id}
className={`w-full flex items-center gap-3 p-3 rounded-lg border text-left transition-all duration-200 cursor-pointer ${
activeCategory === cat.id
? "bg-indigo-500/10 border-indigo-500/30 text-indigo-600 dark:text-indigo-400 font-medium shadow-sm"
: "bg-card border-border hover:bg-muted/50 text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveCategory(cat.id)}
>
<div className={`p-1.5 rounded-md ${activeCategory === cat.id ? "bg-indigo-500/20" : "bg-muted"}`}>
<cat.icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold">{cat.title}</div>
<div className="text-[10px] text-muted-foreground truncate">{cat.desc}</div>
</div>
</button>
))}
</div>
{/* 右侧主展示区 */}
<div className="lg:col-span-3 space-y-6">
{activeCategory === "cards" && (
<motion.div
key="cards"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">卡片组件展示</h3>
<ReceiveBanner />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FeatureCard
title="接入官方 API 接口"
description="使用系统内置的 API 接口,支持快速接入、异步通知和同步跳转等标准流程。"
linkText="查看文档"
href="/docs/api"
/>
<FeatureCard
title="探索开放平台"
description="通过开放平台页面浏览可用能力,快速对接通用系统功能。"
linkText="查看入口"
href="/docs/how-to-use"
/>
<FeatureCard
title="面对面服务"
description="通过系统支持的离线与面对面服务接口,扩展应用功能,处理各种场景下的交互需求。"
linkText="功能开发中,敬请期待"
/>
</div>
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (FeatureCard)</h3>
<CodeShowcase code={codeSnippets.cards} title="FeatureCard.tsx" />
</div>
</motion.div>
)}
{activeCategory === "dashboards" && (
<motion.div
key="dashboards"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">大盘数据组件展示</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4 md:col-span-2">
<MockTrendChart />
</div>
<MockIncomeStats />
<MockTopCustomers />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (IncomeStats)</h3>
<CodeShowcase code={codeSnippets.dashboards} title="IncomeStatsCard.tsx" />
</div>
</motion.div>
)}
{activeCategory === "tables" && (
<motion.div
key="tables"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">活动明细表格展示</h3>
<MockTransactionTable />
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (TransactionTable)</h3>
<CodeShowcase code={codeSnippets.tables} title="TransactionTable.tsx" />
</div>
</motion.div>
)}
{activeCategory === "tabs" && (
<motion.div
key="tabs"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">多标签导航展示</h3>
<MockTradeTabs />
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (MockTradeTabs)</h3>
<CodeShowcase code={codeSnippets.tabs} title="TradeTabs.tsx" />
</div>
</motion.div>
)}
</div>
</div>
</div>
)
}
+27 -2
View File
@@ -1,5 +1,30 @@
import {UserFilesMain} from "@/components/common/user/files"
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {UserFileManager} from "@/components/common/user/file-manager"
import {FolderOpen} from "lucide-react"
export default function UserFilesPage() {
return <UserFilesMain />
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
>
{/* 顶部标题区 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-5">
<div className="flex items-center gap-2">
<FolderOpen className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">我的文件</h1>
</div>
</div>
</div>
<UserFileManager />
</motion.div>
)
}
@@ -0,0 +1,312 @@
"use client"
import * as React from "react"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {HardDrive, RefreshCw, Trash2} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
import {Skeleton} from "@/components/ui/skeleton"
import {Label} from "@/components/ui/label"
import {Input} from "@/components/ui/input"
import {Switch} from "@/components/ui/switch"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type {CacheStatus} from "@/lib/services"
import {AdminService} from "@/lib/services"
/**
* 格式化字节大小
*/
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return "0"
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
}
interface CacheManagerProps {
refreshTrigger: number
}
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null)
const [loadingCache, setLoadingCache] = useState<boolean>(true)
const [savingConfig, setSavingConfig] = useState<boolean>(false)
const [clearingCache, setClearingCache] = useState<boolean>(false)
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false)
// 策略配置表单状态
const [maxSizeMB, setMaxSizeMB] = useState<string>("100")
const [ttlMinutes, setTtlMinutes] = useState<string>("60")
const [lruEnabled, setLruEnabled] = useState<boolean>(true)
// 获取磁盘缓存状态
const fetchCacheStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingCache(true)
try {
const data = await AdminService.getCacheStatus()
setCacheStatus(data)
setMaxSizeMB(data.max_size_mb.toString())
setTtlMinutes(data.ttl_minutes.toString())
setLruEnabled(data.lru_enabled)
} catch (err) {
toast.error("获取磁盘缓存状态失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setLoadingCache(false)
}
}, [])
// 保存配置
const handleSaveConfig = async (e: React.FormEvent) => {
e.preventDefault()
const size = parseInt(maxSizeMB, 10)
const ttl = parseInt(ttlMinutes, 10)
if (isNaN(size) || size < 1) {
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
return
}
if (isNaN(ttl) || ttl < 0) {
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
return
}
setSavingConfig(true)
try {
await AdminService.updateCacheConfig({
max_size_mb: size,
ttl_minutes: ttl,
lru_enabled: lruEnabled,
})
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
await fetchCacheStatus(true)
} catch (err) {
toast.error("保存配置失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setSavingConfig(false)
}
}
// 清空磁盘缓存数据
const handleClearCache = async () => {
setClearingCache(true)
try {
await AdminService.clearCache()
toast.success("清空成功", { description: "缓存数据已全部清除" })
setShowClearConfirm(false)
await fetchCacheStatus(true)
} catch (err) {
toast.error("清空缓存失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setClearingCache(false)
}
}
// 初始化拉取
useEffect(() => {
fetchCacheStatus()
}, [fetchCacheStatus])
// 当外部刷新触发器改变时自动刷新
useEffect(() => {
fetchCacheStatus(true)
}, [refreshTrigger, fetchCacheStatus])
return (
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<HardDrive className="size-4 text-primary animate-pulse" />
<CardTitle className="text-sm font-semibold">缓存管理</CardTitle>
</div>
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
</div>
<Button
size="sm"
variant="secondary"
className="h-8 gap-1.5 text-xs self-start md:self-auto"
onClick={() => fetchCacheStatus(true)}
disabled={loadingCache}
>
<RefreshCw className={`size-3 ${loadingCache ? "animate-spin" : ""}`} />
刷新状态
</Button>
</CardHeader>
<CardContent className="pt-4">
{loadingCache && !cacheStatus ? (
<div className="space-y-3 py-6">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : cacheStatus ? (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左边:状态区 (2/5 cols) */}
<div className="lg:col-span-2 space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">运行状态</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* 已占空间 */}
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
<p className="text-[10px] text-muted-foreground font-medium mb-1">已用空间</p>
<p className="text-xl font-bold tracking-tight text-foreground">
{formatBytes(cacheStatus.total_size)}
</p>
</div>
{/* Key数量 */}
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
<p className="text-[10px] text-muted-foreground font-medium mb-1">缓存键数量</p>
<p className="text-xl font-bold tracking-tight text-foreground">
{formatNumber(cacheStatus.keys_count)} <span className="text-xs text-muted-foreground font-normal">个文件</span>
</p>
</div>
</div>
{/* 存储路径 */}
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
<p className="text-[10px] text-muted-foreground font-medium mb-1.5">缓存基准目录</p>
<code className="text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate" title={cacheStatus.base_path}>
{cacheStatus.base_path}
</code>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-xs font-medium w-full"
onClick={() => setShowClearConfirm(true)}
>
立即清空缓存
</Button>
</div>
{/* 右边:配置区 (3/5 cols) */}
<div className="lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">策略配置</h4>
<form onSubmit={handleSaveConfig} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="maxSizeMB" className="text-xs font-medium">最大容量限制 (MB)</Label>
<Input
id="maxSizeMB"
type="number"
min="1"
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(e.target.value)}
className="h-8 text-xs bg-background/50 border-border/40"
placeholder="例如 100"
required
/>
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
<Input
id="ttlMinutes"
type="number"
min="0"
value={ttlMinutes}
onChange={(e) => setTtlMinutes(e.target.value)}
className="h-8 text-xs bg-background/50 border-border/40"
placeholder="例如 60,0 表示永不过期"
required
/>
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
</div>
</div>
<div className="flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20">
<div className="space-y-1 pr-4">
<Label htmlFor="lruEnabled" className="text-xs font-semibold block cursor-pointer">
启用 LRU 淘汰机制
</Label>
<span className="text-[10px] text-muted-foreground block">
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
</span>
</div>
<Switch
id="lruEnabled"
checked={lruEnabled}
onCheckedChange={setLruEnabled}
/>
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
disabled={savingConfig}
className="h-8 text-xs px-4"
>
{savingConfig ? "正在保存..." : "保存设置"}
</Button>
</div>
</form>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<HardDrive className="size-8 opacity-45 mb-2" />
<span className="text-xs">未加载到缓存状态信息</span>
</div>
)}
</CardContent>
{/* 清除缓存确认对话框 */}
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="size-4" />
确认清空所有缓存?
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground pt-1">
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 justify-end mt-4">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => setShowClearConfirm(false)}
disabled={clearingCache}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 text-xs"
onClick={handleClearCache}
disabled={clearingCache}
>
{clearingCache ? "清理中..." : "确认清空"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
"use client"
import * as React from "react"
import {useRef, useState} from "react"
import {useTheme} from "next-themes"
import CodeMirror from "@uiw/react-codemirror"
import {sql} from "@codemirror/lang-sql"
import {toast} from "sonner"
import {ArrowLeft, Play, RefreshCw, Terminal, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import type {ExecuteSQLResponse} from "@/lib/services"
import services from "@/lib/services"
/**
* 格式化表格单元格内容
*/
const formatCellValue = (val: unknown): string => {
if (val === null || val === undefined) return "-"
if (typeof val === "boolean") return val ? "true" : "false"
if (typeof val === "object") {
try {
return JSON.stringify(val)
} catch {
return "[Object]"
}
}
return String(val)
}
interface SQLConsoleProps {
dbType?: string // "postgres" | "sqlite"
onClose: () => void
}
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
// SQL 控制台状态
const [sqlQuery, setSqlQuery] = useState<string>("")
const [executingSQL, setExecutingSQL] = useState<boolean>(false)
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
const [sqlError, setSqlError] = useState<string | null>(null)
// 拖拽与主题状态
const containerRef = useRef<HTMLDivElement>(null)
const [editorHeight, setEditorHeight] = useState<number>(240)
const { resolvedTheme } = useTheme()
const cmTheme = resolvedTheme === "dark" ? "dark" : "light"
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const startY = e.clientY
const startHeight = editorHeight
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!containerRef.current) return
const deltaY = moveEvent.clientY - startY
const containerHeight = containerRef.current.getBoundingClientRect().height
const newHeight = startHeight + deltaY
// Limit editor height between 80px and containerHeight - 80px
if (newHeight > 80 && newHeight < containerHeight - 80) {
setEditorHeight(newHeight)
}
}
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
}
// 执行 SQL 查询
const handleExecuteSQL = async () => {
if (!sqlQuery.trim()) return
setExecutingSQL(true)
setSqlResult(null)
setSqlError(null)
try {
const result = await services.dbManage.executeSQL(sqlQuery)
setSqlResult(result)
toast.success("SQL 执行成功")
} catch (err) {
setSqlError(err instanceof Error ? err.message : "未知执行错误")
toast.error("SQL 执行失败")
} finally {
setExecutingSQL(false)
}
}
// Preset SQL 选择器
const handlePresetSQLChange = (query: string) => {
setSqlQuery(query)
}
const handleClose = () => {
setSqlResult(null)
setSqlError(null)
onClose()
}
return (
<div className="py-6 px-1 space-y-6 w-full">
{/* 顶部控制与标题 */}
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleClose}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Terminal className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">SQL 查询终端</h1>
</div>
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{dbType === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"}
</div>
</div>
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
<div
ref={containerRef}
className="w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]"
>
{/* 顶部编辑工具栏 */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Terminal className="size-4 text-primary" />
<span className="text-xs font-semibold">SQL 编辑器</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* 快速模板 */}
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">快速模板:</span>
<Select onValueChange={handlePresetSQLChange}>
<SelectTrigger className="h-7 w-[180px] text-[11px] bg-background">
<SelectValue placeholder="选择预设 SQL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SELECT * FROM users LIMIT 10;">查询用户 (SELECT users)</SelectItem>
<SelectItem value="SELECT * FROM uploads LIMIT 10;">查询文件 (SELECT uploads)</SelectItem>
<SelectItem value="SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;">查询任务流水 (SELECT task_executions)</SelectItem>
<SelectItem value="SELECT sqlite_version();">SQLite 版本 (SQLite only)</SelectItem>
<SelectItem value="SELECT version();">PostgreSQL 版本 (Postgres only)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 控制按钮 */}
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setSqlQuery("")}
title="清空编辑器"
>
<Trash2 className="size-3.5" />
</Button>
<Button
size="sm"
className="h-7 px-3 gap-1 text-[11px]"
onClick={handleExecuteSQL}
disabled={executingSQL || !sqlQuery.trim()}
>
<Play className="size-3" />
{executingSQL ? "运行中..." : "运行"}
</Button>
</div>
</div>
</div>
{/* 上半区:编辑器输入框 */}
<div
style={{ height: editorHeight }}
className="w-full overflow-hidden relative min-h-[100px] bg-background"
>
<CodeMirror
value={sqlQuery}
height="100%"
extensions={[sql()]}
theme={cmTheme}
onChange={(value) => setSqlQuery(value)}
className="h-full text-xs font-mono"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: false,
indentOnInput: true,
}}
/>
</div>
{/* 拖动分割线 */}
<div
onMouseDown={handleMouseDown}
className="h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10"
title="拖动调整大小"
>
<div className="w-8 h-1 rounded bg-muted-foreground/30" />
</div>
{/* 下半区:查看结果 (可滑动查看) */}
<div className="flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10">
{/* 结果栏工具提示 */}
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono">
<span className="font-semibold">执行输出</span>
{sqlResult && (
<span>
类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms
</span>
)}
</div>
{/* 滚动结果内容 */}
<div className="flex-1 overflow-auto p-4 min-h-0">
{executingSQL && (
<div className="flex flex-col items-center justify-center h-full py-10 space-y-2">
<RefreshCw className="size-5 text-primary animate-spin" />
<span className="text-xs text-muted-foreground">正在在数据库执行查询,请稍候...</span>
</div>
)}
{sqlError && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]">
<p className="font-semibold mb-1">SQL 执行报错 (Error):</p>
<pre className="whitespace-pre-wrap">{sqlError}</pre>
</div>
)}
{sqlResult && (
<div className="h-full flex flex-col">
{sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && (
<div className="border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0">
<Table className="text-xs">
<TableHeader className="bg-muted/40 font-semibold sticky top-0 z-10">
<TableRow>
{sqlResult.columns.map((col) => (
<TableHead key={col} className="font-semibold text-foreground py-2">{col}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{sqlResult.results && sqlResult.results.length > 0 ? (
sqlResult.results.map((row, rIndex) => (
<TableRow key={rIndex} className="hover:bg-muted/10">
{sqlResult.columns!.map((col) => (
<TableCell key={col} className="font-mono py-1.5">
<span className="truncate max-w-[240px] block" title={formatCellValue(row[col])}>
{formatCellValue(row[col])}
</span>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
查询结果为空
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{sqlResult.type === "exec" && (
<div className="bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto">
<p className="text-foreground text-sm font-semibold">SQL 执行成功</p>
<p className="text-muted-foreground mt-2">
受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。
</p>
</div>
)}
</div>
)}
{!executingSQL && !sqlResult && !sqlError && (
<div className="flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60">
<Terminal className="size-8 mb-2" />
<span className="text-xs">编辑器就绪,请在上方编写 SQL 并运行查看结果</span>
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,211 @@
"use client"
import * as React from "react"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Layers, RefreshCw} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Skeleton} from "@/components/ui/skeleton"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Button} from "@/components/ui/button"
import type {TableDataResponse} from "@/lib/services"
import services from "@/lib/services"
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return "0"
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
}
/**
* 格式化表格单元格内容
*/
const formatCellValue = (val: unknown): string => {
if (val === null || val === undefined) return "-"
if (typeof val === "boolean") return val ? "true" : "false"
if (typeof val === "object") {
try {
return JSON.stringify(val)
} catch {
return "[Object]"
}
}
return String(val)
}
interface TableBrowserProps {
tables: string[]
loadingTables: boolean
refreshTrigger: number
}
export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) {
const [selectedTable, setSelectedTable] = useState<string>("")
const [tableData, setTableData] = useState<TableDataResponse | null>(null)
const [page, setPage] = useState<number>(1)
const [loadingData, setLoadingData] = useState<boolean>(false)
const pageSize = 10
// 获取具体表数据
const fetchTableData = useCallback(async (tableName: string, targetPage: number, size: number) => {
if (!tableName) return
setLoadingData(true)
try {
const data = await services.dbManage.getTableData({
table: tableName,
page: targetPage,
pageSize: size,
})
setTableData(data)
} catch (err) {
toast.error(`获取数据表 ${tableName} 数据失败`, {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setLoadingData(false)
}
}, [])
// 默认选择第一张表
useEffect(() => {
if (tables.length > 0 && !selectedTable) {
setSelectedTable(tables[0])
}
}, [tables, selectedTable])
// 当选中的表、页码或外部刷新触发器改变时获取数据
useEffect(() => {
if (selectedTable) {
fetchTableData(selectedTable, page, pageSize)
}
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
// 处理页码改变
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
// 处理表切换
const handleTableChange = (value: string) => {
setSelectedTable(value)
setPage(1) // 切换表重置为第一页
}
return (
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">数据表浏览器</CardTitle>
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
</div>
<div className="flex items-center gap-2">
{loadingTables ? (
<Skeleton className="h-8 w-48" />
) : (
<Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger className="h-8 w-[200px] text-xs bg-background border-border/40">
<SelectValue placeholder="选择数据表" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{tables.map((t) => (
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
{loadingData && !tableData ? (
<div className="space-y-3 py-6">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : tableData && tableData.columns.length > 0 ? (
<div className="space-y-4">
{/* 数据表格区域 */}
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
{loadingData && (
<div className="absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10">
<RefreshCw className="size-5 text-primary animate-spin" />
</div>
)}
<Table className="text-xs">
<TableHeader className="bg-muted/40 font-semibold sticky top-0">
<TableRow>
{tableData.columns.map((col) => (
<TableHead key={col} className="font-semibold text-foreground py-2.5">{col}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tableData.results && tableData.results.length > 0 ? (
tableData.results.map((row, rIndex) => (
<TableRow key={rIndex} className="hover:bg-muted/10">
{tableData.columns.map((col) => (
<TableCell key={col} className="font-mono py-2">
<span className="truncate max-w-[200px] block" title={formatCellValue(row[col])}>
{formatCellValue(row[col])}
</span>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
表中无记录数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页控制 */}
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
<div>
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData}
>
上一页
</Button>
<span className="text-xs px-2 font-mono">
第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页
</span>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => handlePageChange(page + 1)}
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
>
下一页
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Layers className="size-8 opacity-45 mb-2" />
<span className="text-xs">未选择数据表或表结构无法加载</span>
</div>
)}
</CardContent>
</Card>
)
}
@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {FileStats} from "./file-stats"
import {FileList} from "./file-list"
import {StorageConfigTab} from "./storage-config-tab"
export function FilesMain() {
const [activeTab, setActiveTab] = React.useState("stats")
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
>
{/* 顶部标题区 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-5">
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
存储管理
</h1>
<p className="text-sm text-muted-foreground">
管理系统存储的所有文件,支持下载、数据统计与批量操作
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-col gap-6">
<TabsList className="grid w-fit grid-cols-3">
<TabsTrigger value="stats">文件存储信息</TabsTrigger>
<TabsTrigger value="list">文件列表</TabsTrigger>
<TabsTrigger value="storage">存储配置</TabsTrigger>
</TabsList>
{/* ──────── TAB 1: 统计看板 ──────── */}
<TabsContent value="stats" className="outline-hidden">
<FileStats />
</TabsContent>
{/* ──────── TAB 2: 文件列表 ──────── */}
<TabsContent value="list" className="outline-hidden">
<FileList />
</TabsContent>
<TabsContent value="storage" className="outline-hidden">
<StorageConfigTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
@@ -1,65 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {Bell, History, Layers, Settings} from "lucide-react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {EventsTab} from "./push/events-tab"
import {HistoriesTab} from "./push/histories-tab"
import {SettingsTab} from "./push/settings-tab"
export function PushNotificationManager() {
const [activeTab, setActiveTab] = React.useState("events")
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="w-full py-6 space-y-6"
>
<div className="flex items-center gap-2">
<Bell className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8">
<TabsTrigger value="events" className="px-0 pb-2 text-xs font-semibold">
<Layers className="size-3.5 mr-1" />
事件管理
</TabsTrigger>
<TabsTrigger value="histories" className="px-0 pb-2 text-xs font-semibold">
<History className="size-3.5 mr-1" />
通知历史
</TabsTrigger>
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
<Settings className="size-3.5 mr-1" />
通道管理与设置
</TabsTrigger>
</TabsList>
{/* ==================== 1. 事件管理 TAB ==================== */}
<TabsContent value="events" className="focus-visible:outline-none">
<EventsTab />
</TabsContent>
{/* ==================== 2. 通知历史 TAB ==================== */}
<TabsContent value="histories" className="focus-visible:outline-none">
<HistoriesTab />
</TabsContent>
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
<TabsContent value="settings" className="focus-visible:outline-none">
<SettingsTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
+7 -5
View File
@@ -157,12 +157,14 @@ export function SystemStatusManager() {
}
return (
<div className="space-y-6 p-1">
<div className="py-6 px-1 space-y-6">
{/* 顶部控制栏 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-border/30 pb-4 gap-4">
<div>
<h2 className="text-xl font-bold tracking-tight">系统状态</h2>
<p className="text-xs text-muted-foreground">监控后端服务的核心运行时状态与内存指标</p>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
<div className="flex items-center gap-2">
<Activity className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">系统状态</h1>
</div>
</div>
<div className="flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm">
@@ -1,66 +0,0 @@
/*
Copyright 2026 Arctel.net
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
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 "./app-logs"
import {AccessLogs} from "./access-logs"
import {AccessAnalytics} from "./access-analytics"
export function SystemLogs() {
return (
<div className="flex flex-col h-full space-y-4 py-6">
{/* Header */}
<div className="flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
</div>
</div>
{/* Tabs Layout */}
<Tabs defaultValue="analytics" className="w-full flex-grow flex flex-col gap-2">
<TabsList variant="line">
<TabsTrigger value="analytics" className="flex items-center gap-1.5 px-4">
<BarChart3 className="size-3.5" />
访问分析
</TabsTrigger>
<TabsTrigger value="access" className="flex items-center gap-1.5 px-4">
<Activity className="size-3.5" />
用户访问日志
</TabsTrigger>
<TabsTrigger value="app" className="flex items-center gap-1.5 px-4">
<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>
)
}
-237
View File
@@ -1,237 +0,0 @@
"use client"
import * as React from "react"
import {Input} from "@/components/ui/input"
import {Switch} from "@/components/ui/switch"
import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel"
import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {formatDateTime} from "@/lib/utils"
import type {SystemConfig} from "@/lib/services"
import {useAdmin} from "@/contexts/admin-context"
/**
* 系统配置
* 显示系统配置的详细信息和编辑面板
*
* @example
* ```tsx
* <SystemConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {SystemConfig} config - 系统配置
* @param {Partial<SystemConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 系统配置详情面板组件
*/
function SystemConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-1 gap-0">
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
}}
/>
) : (
<Input
type={
config?.key && (
config.key.endsWith('_limit') ||
config.key.endsWith('_minutes') ||
config.key.endsWith('_days') ||
config.key.endsWith('_seconds') ||
config.key.endsWith('_hours') ||
config.key.endsWith('_ttl') ||
config.key.endsWith('_max') ||
config.key.endsWith('_min') ||
config.key.includes('max_') ||
config.key.includes('min_') ||
config.key.includes('limit') ||
config.key.includes('count')
) ? "number" : "text"
}
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('value', '')
return
}
onEditDataChange('value', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
style={{
MozAppearance: 'textfield'
}}
/>
)}
</div>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
<div className="flex gap-1 w-[90%]">
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
/>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">公共可见</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
onCheckedChange={(checked) => {
onEditDataChange('visibility', checked ? 1 : 0)
}}
/>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
config?.type === 'system'
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}>
{config?.type === 'system' ? '系统配置' : '业务配置'}
</span>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 系统配置管理组件
*
* @example
* ```tsx
* <SystemConfigs />
* ```
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const {
systemConfigs: configs,
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig
} = useAdmin()
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
React.useEffect(() => {
refetchSystemConfigs(activeTab)
}, [activeTab, refetchSystemConfigs])
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
visibility: config.visibility,
description: config.description
})
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
visibility: editData.visibility ?? config.visibility,
description: editData.description ?? config.description
})
}
return (
<ManagePage<SystemConfig>
title="系统配置"
data={configs}
loading={loading}
error={error}
onReload={() => refetchSystemConfigs(activeTab)}
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
headerExtra={
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'system' | 'business')} className="w-[180px]">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="business" className="text-[11px] h-7">业务配置</TabsTrigger>
<TabsTrigger value="system" className="text-[11px] h-7">系统配置</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "公共可见", cell: (item) => <span>{item.visibility === 1 ? "可见" : "不可见"}</span>, width: "80px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
@@ -0,0 +1,198 @@
"use client"
import * as React from "react"
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import type {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
interface UserDetailSheetProps {
selectedUser: AdminUser | null
isOpen: boolean
onOpenChange: (open: boolean) => void
detailLoading: boolean
onStatusToggle: (user: AdminUser) => Promise<void>
onDeleteTarget: (user: AdminUser) => void
}
export function UserDetailSheet({
selectedUser,
isOpen,
onOpenChange,
detailLoading,
onStatusToggle,
onDeleteTarget,
}: UserDetailSheetProps) {
const displayValue = (value?: string) => value && value.trim() ? value : "-"
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
{detailLoading && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在刷新详情
</div>
)}
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Mail className="size-3" />
邮箱
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Smartphone className="size-3" />
手机
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="size-3 flex items-center justify-center font-bold text-[9px]">⚧</span>
性别
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<MapPin className="size-3" />
所在地
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Globe className="size-3" />
网站
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
</div>
<div className="flex flex-col gap-2 p-3.5 text-sm">
<span className="text-[10px] text-muted-foreground">简介</span>
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">账户状态</span>
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
{selectedUser.is_active ? "正常" : "禁用"}
</Badge>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">管理员</span>
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => onStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
<Button
variant="outline"
className="w-full h-9 text-xs font-medium"
onClick={() => onDeleteTarget(selectedUser)}
>
<Trash2 className="size-3 mr-1" />
删除用户
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,237 @@
"use client"
import * as React from "react"
import {ChevronDown, ChevronLeft, ChevronRight, Filter, Loader2, Search, X} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Separator} from "@/components/ui/separator"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {cn} from "@/lib/utils"
export function UserFilterBar() {
const {
total,
loading,
page,
pageSize,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers
} = useAdminUsers()
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
}
-659
View File
@@ -1,659 +0,0 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Separator} from "@/components/ui/separator"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Globe,
Layers,
Loader2,
Mail,
MapPin,
Plus,
Search,
ShieldCheck,
Smartphone,
Trash2,
UserCheck,
UserX,
VenusAndMars,
X
} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./create-user-modal"
export function UsersManager() {
const {
users,
total,
loading,
error,
page,
pageSize,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers,
getUserDetail,
updateUserStatus,
deleteUser
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user)
if (selectedUser?.id === user.id) {
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
}
}
const handleShowDetail = async (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
setDetailLoading(true)
try {
const detail = await getUserDetail(user.id)
setSelectedUser(detail)
} catch {
setSelectedUser(user)
} finally {
setDetailLoading(false)
}
}
const handleDeleteUser = async () => {
if (!deleteTarget) return
setDeleteLoading(true)
try {
await deleteUser(deleteTarget)
if (selectedUser?.id === deleteTarget.id) {
setDetailOpen(false)
setSelectedUser(null)
}
setDeleteTarget(null)
} finally {
setDeleteLoading(false)
}
}
const displayValue = (value?: string) => value && value.trim() ? value : "-"
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
const renderFilterBar = () => (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
return (
<div className="py-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增用户
</Button>
</div>
{renderFilterBar()}
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
</TooltipContent>
</Tooltip>
</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)}>
<Eye className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
查看详情
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!user.is_admin && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除用户
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
{detailLoading && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在刷新详情
</div>
)}
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Mail className="size-3" />
邮箱
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Smartphone className="size-3" />
手机
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<VenusAndMars className="size-3" />
性别
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<MapPin className="size-3" />
所在地
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Globe className="size-3" />
网站
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
</div>
<div className="flex flex-col gap-2 p-3.5 text-sm">
<span className="text-[10px] text-muted-foreground">简介</span>
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">账户状态</span>
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
{selectedUser.is_active ? "正常" : "禁用"}
</Badge>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">管理员</span>
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => handleStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
<Button
variant="outline"
className="w-full h-9 text-xs font-medium"
onClick={() => setDeleteTarget(selectedUser)}
>
<Trash2 className="size-3 mr-1" />
删除用户
</Button>
</div>
)}
</>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
}
@@ -22,6 +22,7 @@ import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
interface ManagePageProps<T> {
title: string
icon?: LucideIcon
data: T[]
loading: boolean
error: Error | null
@@ -61,6 +62,7 @@ interface ManagePageProps<T> {
export function ManagePage<T>({
title,
icon: Icon,
data,
loading,
error,
@@ -201,8 +203,13 @@ export function ManagePage<T>({
return (
<div className="py-6">
<div className="flex border-b border-border pb-2 mb-6 items-center justify-between">
<div className="text-2xl font-semibold">{title}</div>
<div className="flex pb-2 mb-6 items-center justify-between">
<div className="flex items-center gap-2">
{Icon && <Icon className="size-5 text-primary" />}
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
</div>
</div>
{headerExtra}
</div>
+42 -2
View File
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {AnimatePresence, motion} from "motion/react"
import {useUser} from "@/contexts/user-context"
import {Card, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
@@ -10,6 +10,7 @@ import Link from "next/link"
export function HomeMain() {
const { user } = useUser()
const [isBannerVisible, setIsBannerVisible] = React.useState(true)
const quickLinks = [
{
@@ -93,10 +94,49 @@ export function HomeMain() {
return (
<div className="py-6 space-y-8 max-w-6xl mx-auto">
{/* 积分收益提示 banner */}
<AnimatePresence>
{isBannerVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
>
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative">
<div className="max-w-xl space-y-3">
<h3 className="text-lg font-bold text-indigo-600 dark:text-indigo-400">Modern Platform</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
通用的、现代化的后台管理系统脚手架, 开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
</p>
<div className="flex gap-2.5">
<Button
size="sm"
className="bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7"
asChild
>
<Link href="/home">开始使用</Link>
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7"
onClick={() => setIsBannerVisible(false)}
>
隐藏提示
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 快捷导航 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">常用功能导航</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{quickLinks.map((link, idx) => (
<motion.div
key={link.title}
@@ -1,114 +0,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 {OperationTab} from "./operation-tab"
import {AdminService} from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
import {SystemStatusManager} from "@/components/common/admin/status"
import {SecurityTab} from "./security-tab"
import {SystemTab} from "./system-tab"
import {OtherTab} from "./other-tab"
import {InfoTab} from "./info-tab"
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config
return accumulator
}, {})
}
export function SystemSettingsMain() {
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-muted-foreground" />
<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">
<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="pt-4">
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
</TabsContent>
<TabsContent value="operation" className="pt-4">
<OperationTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
</TabsContent>
<TabsContent value="system" className="pt-4">
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
</TabsContent>
<TabsContent value="status" className="pt-4">
<SystemStatusManager />
</TabsContent>
<TabsContent value="other" className="pt-4">
<OtherTab configs={configs} />
</TabsContent>
<TabsContent value="info" className="pt-4">
<InfoTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
-31
View File
@@ -1,31 +0,0 @@
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {UserFileManager} from "./file-manager"
export function UserFilesMain() {
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex w-full flex-col gap-6 py-6"
>
{/* 顶部标题区 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-5">
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
我的文件
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的个人文件,支持上传、下载、搜索与删除操作
</p>
</div>
</div>
<UserFileManager />
</motion.div>
)
}
+3 -3
View File
@@ -45,7 +45,9 @@ import {
} from "@/components/ui/alert-dialog"
import {
ArrowUpRight,
Bell,
ChevronDown,
Code,
CreditCard,
Database,
FileQuestionMark,
@@ -54,12 +56,10 @@ import {
Home,
Layers,
LogOut,
Palette,
Settings,
ShieldCheck,
Terminal,
UserRound,
Bell,
} from "lucide-react"
import {useUser} from "@/contexts/user-context"
@@ -82,7 +82,7 @@ const data = {
{ title: "系统设置", url: "/admin/settings", icon: Settings },
],
document: [
{ title: "组件库", url: "/components", icon: Palette },
{ title: "规范示例", url: "/admin/demo", icon: Code },
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
],