mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
refactor(frontend): optimization
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user