wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# 前端 URL
WAVELET_FRONTEND_URL=http://localhost:3000
# 后端 URL
WAVELET_BACKEND_URL=http://localhost:8000
# Session Cookie
WAVELET_SESSION_COOKIE_NAME=wavelet_session_id
# 环境
NODE_ENV=production
+50
View File
@@ -0,0 +1,50 @@
# Wavelet Frontend
Modern frontend application for the Wavelet scaffold framework.
[中文](./README_zh.md) | English
## Tech Stack
- **Framework**: [Next.js 16](https://nextjs.org/)
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/)
- **UI Components**: [Radix UI](https://www.radix-ui.com/)
- **Icons**: [Lucide React](https://lucide.dev/)
- **Package Manager**: [pnpm](https://pnpm.io/)
## Getting Started
### Prerequisites
- Node.js (Latest LTS recommended)
- pnpm
### Installation
1. Install dependencies:
```bash
pnpm install
```
2. Run the development server:
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) (or the port shown in your terminal) with your browser to see the result.
## Project Structure
- `app/`: Next.js App Router pages and layouts
- `components/`: Reusable UI components
- `ui/`: Base UI components (buttons, inputs, etc.)
- `common/`: Shared business components
- `lib/`: Utility functions and service definitions
- `public/`: Static assets
## Scripts
- `pnpm dev`: Start development server with Turbopack
- `pnpm build`: Build the application for production
- `pnpm start`: Start production server
- `pnpm lint`: Run ESLint
+50
View File
@@ -0,0 +1,50 @@
# Wavelet Frontend
Wavelet 脚手架系统的现代化前端应用。
[English](./README.md) | 中文
## 技术栈
- **框架**: [Next.js 16](https://nextjs.org/)
- **样式**: [Tailwind CSS 4](https://tailwindcss.com/)
- **UI 组件**: [Radix UI](https://www.radix-ui.com/)
- **图标**: [Lucide React](https://lucide.dev/)
- **包管理器**: [pnpm](https://pnpm.io/)
## 快速开始
### 前置要求
- Node.js (建议使用最新的 LTS 版本)
- pnpm
### 安装
1. 安装依赖:
```bash
pnpm install
```
2. 运行开发服务器:
```bash
pnpm dev
```
在浏览器中打开 [http://localhost:3000](http://localhost:3000) (或终端中显示的端口) 查看结果。
## 项目结构
- `app/`: Next.js App Router 页面和布局
- `components/`: 可复用 UI 组件
- `ui/`: 基础 UI 组件 (按钮, 输入框等)
- `common/`: 共享业务组件
- `lib/`: 工具函数和服务定义
- `public/`: 静态资源
## 脚本
- `pnpm dev`: 启动开发服务器 (使用 Turbopack)
- `pnpm build`: 构建生产版本
- `pnpm start`: 启动生产服务器
- `pnpm lint`: 运行 ESLint
@@ -0,0 +1,10 @@
import {Suspense} from "react"
import {LoginPage} from "@/components/auth/login-page"
export default function Page() {
return (
<Suspense>
<LoginPage />
</Suspense>
)
}
@@ -0,0 +1,10 @@
import {Suspense} from "react"
import {RegisterPage} from "@/components/auth/register-page"
export default function Page() {
return (
<Suspense>
<RegisterPage />
</Suspense>
)
}
@@ -0,0 +1,17 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
import {apiSections, DOCS_LAST_UPDATED} from "@/components/common/docs/api";
export default function ApiDocPage() {
return (
<LegalPageLayout
title="API 接口文档"
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
Wavelet 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。
</p>
}
/>
);
}
@@ -0,0 +1,18 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
import {howToUseSections} from "@/components/common/docs/how-to-use";
import {DOCS_LAST_UPDATED} from "@/components/common/docs/api";
export default function HowToUsePage() {
return (
<LegalPageLayout
title="平台使用指南"
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
description={
<p className="text-muted-foreground text-sm leading-relaxed">
欢迎使用 Wavelet 脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
</p>
}
/>
);
}
@@ -0,0 +1,20 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"
import {privacySections} from "@/components/common/docs/privacy"
import {TERMS_LAST_UPDATED} from "@/components/common/docs/terms"
export default function PrivacyPolicyPage() {
return (
<LegalPageLayout
title="隐私政策 (Privacy)"
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
<br className="hidden md:block" />
以及您享有的相关权利。
</span>
}
/>
)
}
@@ -0,0 +1,17 @@
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"
import {TERMS_LAST_UPDATED, termsSections} from "@/components/common/docs/terms"
export default function TermsOfServicePage() {
return (
<LegalPageLayout
title="服务协议 (Terms)"
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={
<span>
为了保障您的合法权益,请您仔细阅读以下条款。
</span>
}
/>
)
}
@@ -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/admin"
import services from "@/lib/services"
/**
* 格式化字节大小
*/
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
/**
* 格式化数字,每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 services.adminCache.getCacheStatus()
setCacheStatus(data)
setMaxSizeMB(data.max_size_mb.toString())
setTtlMinutes(data.ttl_minutes.toString())
setLruEnabled(data.lru_enabled)
} catch (err) {
toast.error("获取磁盘缓存状态失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} 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 services.adminCache.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 services.adminCache.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>
)
}
@@ -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/db-manage"
import {DbManageService} from "@/lib/services/db-manage"
/**
* 格式化表格单元格内容
*/
const formatCellValue = (val: unknown): string => {
if (val === null || val === undefined) return "-"
if (typeof val === "boolean") return val ? "true" : "false"
if (typeof val === "object") {
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 DbManageService.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/db-manage"
import {DbManageService} from "@/lib/services/db-manage"
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return "0"
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
}
/**
* 格式化表格单元格内容
*/
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 DbManageService.getTableData({
table: tableName,
page: targetPage,
pageSize: size,
})
setTableData(data)
} catch (err) {
toast.error(`获取数据表 ${tableName} 数据失败`, {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setLoadingData(false)
}
}, [])
// 默认选择第一张表
useEffect(() => {
if (tables.length > 0 && !selectedTable) {
setSelectedTable(tables[0])
}
}, [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>
)
}
@@ -0,0 +1,329 @@
"use client"
import dynamic from "next/dynamic"
import * as React from "react"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {
Activity,
Cpu,
Database,
Download,
FileText,
HardDrive,
Layers,
RefreshCw,
Server,
Terminal,
} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
import {Skeleton} from "@/components/ui/skeleton"
import services from "@/lib/services"
import type {DBOverview} from "@/lib/services/db-manage"
const sectionFallback = (
<div className="h-48 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
)
const TableBrowser = dynamic(
() => import("./components/table-browser").then((mod) => mod.TableBrowser),
{ loading: () => sectionFallback },
)
const CacheManager = dynamic(
() => import("./components/cache-manager").then((mod) => mod.CacheManager),
{ loading: () => sectionFallback },
)
const SQLConsole = dynamic(
() => import("./components/sql-console").then((mod) => mod.SQLConsole),
{ ssr: false },
)
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return "0"
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
export function DatabasePageClient() {
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null)
const [tables, setTables] = useState<string[]>([])
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
const [loadingTables, setLoadingTables] = useState<boolean>(true)
const [exporting, setExporting] = useState<boolean>(false)
// 协调子组件刷新与视图切换状态
const [showConsole, setShowConsole] = useState<boolean>(false)
const [refreshTrigger, setRefreshTrigger] = useState<number>(0)
// 1. 获取运行概览
const fetchOverview = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true)
try {
const data = await 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 services.dbManage.exportDatabase()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
} catch (err) {
toast.error("数据库导出失败", {
description: err instanceof Error ? err.message : "导出异常",
})
} 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>
)
}
@@ -0,0 +1,5 @@
import {DatabasePageClient} from "./page-client"
export default function DatabasePage() {
return <DatabasePageClient />
}
@@ -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">tabs/events-tab.tsx</code> 或就近放在 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/</code> 下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。</li>
<li><strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs 切换,当一个页面代码行数超过 600 行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">app/(main)/admin/database/components/</code>),只有真正跨页面复用的组件才进入全局 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/common/</code>。典型标杆案例参考“数据管理” (<code className="bg-muted px-1 py-0.5 rounded font-mono">/admin/database</code>) 的 <code className="bg-muted px-1 py-0.5 rounded font-mono">table-browser.tsx</code>、<code className="bg-muted px-1 py-0.5 rounded font-mono">cache-manager.tsx</code> 与 <code className="bg-muted px-1 py-0.5 rounded font-mono">sql-console.tsx</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>
)
}
@@ -0,0 +1,535 @@
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react"
import {
Download,
Eye,
FileArchive,
FileAudio,
FileImage,
FileText,
FileVideo,
Loader2,
Search,
Trash2,
Upload,
X,
} from "lucide-react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Checkbox} from "@/components/ui/checkbox"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {FileImagePreview} from "@/components/common/file-image-preview"
import services, {formatFileSize} from "@/lib/services"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-8") {
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
export function FileList() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
const [page, setPage] = React.useState(1)
const pageSize = 15 // 表格视图下 15 条更紧凑
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
// 文件列表查询
const listQuery = useQuery({
queryKey: ["files", "all", page, pageSize, debouncedKeyword],
queryFn: () => services.adminUpload.listUploads(page, pageSize, debouncedKeyword || undefined),
})
const storageDriverQuery = useQuery({
queryKey: ["admin", "storage-config", "driver"],
queryFn: async () => {
const record = await services.adminSystemConfig.getSystemConfig("storage_config")
const cfg = JSON.parse(record.value) as {driver?: string}
return cfg.driver ?? "local"
},
})
const files = listQuery.data?.items ?? []
const total = listQuery.data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
const handleSelectAll = () => {
if (isAllSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f.id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.add(f.id))
return next
})
}
}
// 删除单文件
const deleteMutation = useMutation({
mutationFn: (id: string) => services.adminUpload.deleteFile(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
toast.success("文件已删除")
setDeleteTarget(null)
},
onError: (err: Error) => toast.error(err.message || "删除失败"),
})
// 批量 ZIP 下载
const batchDownloadMutation = useMutation({
mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "batch_download.zip"
a.click()
URL.revokeObjectURL(url)
toast.success("批量下载已开始")
},
onError: () => toast.error("批量下载失败"),
})
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const clearSelection = () => setSelectedIds(new Set())
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
const handleDownload = (file: UploadRecord) => {
const url = services.adminUpload.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
return (
<div className="space-y-6">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{keyword && (
<button
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setKeyword("")}
>
<X className="size-3" />
</button>
)}
</div>
{files.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-xs h-8 border border-dashed text-muted-foreground"
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
>
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
</Button>
)}
{/* 打包下载等批量操作 */}
<div className="flex items-center gap-2 ml-auto shrink-0">
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2"
>
<Badge variant="secondary" className="text-xs px-2.5">
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
disabled={batchDownloadMutation.isPending}
>
{batchDownloadMutation.isPending ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<FileArchive className="size-3.5 mr-1" />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
onClick={clearSelection}
>
<X className="size-3.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
)}
</div>
{/* 文件列表 Table */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground">
<Upload className="size-12 text-muted-foreground/30" />
<p className="text-sm">
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
</p>
</div>
) : (
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-dashed">
<TableHead className="w-[50px] pl-4 py-3">
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-[80px] py-3">预览</TableHead>
<TableHead className="w-[180px] py-3">ID</TableHead>
<TableHead className="py-3">文件名</TableHead>
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
<TableHead className="w-[100px] py-3">业务类别</TableHead>
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
<TableHead className="w-[100px] py-3">大小</TableHead>
<TableHead className="w-[150px] py-3">上传时间</TableHead>
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const isSelected = selectedIds.has(file.id)
return (
<TableRow
key={file.id}
className={`border-dashed hover:bg-muted/30 transition-colors ${
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
}`}
>
<TableCell className="pl-4 py-3">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
{file.mime_type.startsWith("image/") ? (
<FileImagePreview
fileId={file.id}
alt={file.file_name}
quality="low"
variant="compact"
className="size-full object-cover"
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
{file.id}
</TableCell>
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
{file.file_name}
</TableCell>
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
{file.file_path}
</TableCell>
<TableCell className="py-3">
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
{file.type}
</Badge>
</TableCell>
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
{file.mime_type}
</TableCell>
<TableCell className="py-3 text-xs">
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{formatDate(file.created_at)}
</TableCell>
<TableCell className="py-3 text-right pr-4">
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看详情"
onClick={() => setDetailTarget(file)}
>
<Eye className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
)}
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
<FileImagePreview
fileId={detailTarget.id}
alt={detailTarget.file_name}
quality="low"
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
fallbackClassName="min-h-32 w-full rounded-lg"
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
{detailTarget.extension} 文件
</span>
</div>
)}
</div>
{/* 元数据列表 */}
<div className="space-y-4 text-xs">
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件 ID</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{storageDriverQuery.data ?? "local"}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</span>
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,468 @@
"use client"
import * as React from "react"
import {useQuery} from "@tanstack/react-query"
import {Database, Files, HardDrive, Info, Loader2, TrendingUp, Upload,} from "lucide-react"
import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import services, {formatFileSize} from "@/lib/services"
const categoryMap: Record<string, string> = {
"图片": "images",
"视频": "videos",
"音频": "audio",
"文档": "documents",
"压缩包": "archives",
"其他": "others",
}
const statsChartConfig = {
images: {
label: "图片",
color: "hsl(var(--chart-1))",
},
videos: {
label: "视频",
color: "hsl(var(--chart-2))",
},
audio: {
label: "音频",
color: "hsl(var(--chart-3))",
},
documents: {
label: "文档",
color: "hsl(var(--chart-4))",
},
archives: {
label: "压缩包",
color: "hsl(var(--chart-5))",
},
others: {
label: "其他",
color: "hsl(var(--chart-6))",
},
count: {
label: "新增文件数",
color: "hsl(var(--primary))",
},
size: {
label: "新增大小",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig
export function FileStats() {
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
const statsQuery = useQuery({
queryKey: ["files", "stats"],
queryFn: () => services.adminUpload.getFileStats(),
staleTime: 0,
refetchOnMount: "always",
})
const stats = statsQuery.data
const trendData = React.useMemo(() => {
if (!stats?.trend) return []
return stats.trend.map((item) => ({
...item,
formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD
value: trendMetric === "count" ? item.count : item.size,
}))
}, [stats?.trend, trendMetric])
const categoryCountData = React.useMemo(() => {
if (!stats?.categories) return []
return stats.categories
.filter((c) => c.count > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
return {
name: key,
value: c.count,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
const categorySizeData = React.useMemo(() => {
if (!stats?.categories) return []
return stats.categories
.filter((c) => c.size > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
return {
name: key,
value: c.size,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) {
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
}
let maxCountName = "无"
let maxCount = 0
let maxSizeName = "无"
let maxSize = 0
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count
maxCountName = cat.name
}
if (cat.size > maxSize) {
maxSize = cat.size
maxSizeName = cat.name
}
})
return { maxCountName, maxCount, maxSizeName, maxSize }
}, [stats?.categories])
const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 }
return stats.trend.reduce(
(acc, curr) => ({
count: acc.count + curr.count,
size: acc.size + curr.size,
}),
{ count: 0, size: 0 }
)
}, [stats?.trend])
if (statsQuery.isLoading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="size-8 animate-spin text-sky-500" />
</div>
)
}
if (!stats || stats.total_count === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
<Upload className="size-14 text-muted-foreground/25" />
<div className="text-center space-y-1">
<p className="font-medium">暂无上传数据</p>
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* 四个指标卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">总文件数</CardTitle>
<Files className="size-4 text-sky-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-sky-500">
{stats.total_count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">占用存储容量</CardTitle>
<Database className="size-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增文件</CardTitle>
<TrendingUp className="size-4 text-indigo-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-indigo-500">
+{trendSummary.count.toLocaleString()}{" "}
<span className="text-xs font-normal text-muted-foreground">个</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
</CardContent>
</Card>
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增大小</CardTitle>
<HardDrive className="size-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
</CardContent>
</Card>
</div>
{/* 7 天新增趋势折线图 */}
<Card className="bg-card/20 border-border/40">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
</div>
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
<Button
size="sm"
variant={trendMetric === "count" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("count")}
>
新增数量 (个)
</Button>
<Button
size="sm"
variant={trendMetric === "size" ? "secondary" : "ghost"}
className="h-7 text-xs px-2.5 rounded-md"
onClick={() => setTrendMetric("size")}
>
新增大小 (MB)
</Button>
</div>
</CardHeader>
<CardContent className="pl-2 pr-4 pt-2">
<div className="h-[280px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
</linearGradient>
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
<XAxis
dataKey="formattedDate"
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value) => {
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
const formattedValue =
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
return (
<>
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full animate-pulse"
style={{
backgroundColor:
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
}}
/>
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formattedValue}
</span>
</>
)
}}
/>
}
/>
<Area
type="monotone"
dataKey="value"
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
strokeWidth={2.5}
fillOpacity={1}
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
/>
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
{/* 两个饼图分布 */}
<div className="grid gap-6 md:grid-cols-2">
{/* 饼图 1: 格式数量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categoryCountData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
暂无分类数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categoryCountData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{value} 个
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
<div className="bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400">
<Info className="size-4 shrink-0 mt-0.5" />
<p className="leading-normal">
文件类型中数量最多的是{" "}
<strong className="font-bold">「{maxStats.maxCountName}」</strong>,累计上传了{" "}
<strong className="font-mono font-bold">{maxStats.maxCount}</strong> 个文件。
</p>
</div>
</CardContent>
</Card>
{/* 饼图 2: 格式容量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<CardHeader>
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
{categorySizeData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
暂无容量数据
</div>
) : (
<div className="h-[240px] w-full">
<ChartContainer config={statsChartConfig} className="w-full h-full">
<PieChart>
<Pie
data={categorySizeData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => {
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
const label = configObj?.label || name
const color = configObj?.color || "hsl(var(--muted))"
return (
<>
<div className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground">{label}</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
{formatFileSize(Number(value))}
</span>
</>
)
}}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey="name" />}
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
/>
</PieChart>
</ChartContainer>
</div>
)}
<div className="bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400">
<Info className="size-4 shrink-0 mt-0.5" />
<p className="leading-normal">
文件类型中占用存储最大的是{" "}
<strong className="font-bold">「{maxStats.maxSizeName}」</strong>,共消耗了{" "}
<strong className="font-mono font-bold">{formatFileSize(maxStats.maxSize)}</strong>{" "}
存储容量。
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,415 @@
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Database, Loader2, Play, Save} from "lucide-react"
import {toast} from "sonner"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
import {Input} from "@/components/ui/input"
import {Progress} from "@/components/ui/progress"
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import type {
ObjectStorageConfig,
StorageConfig,
StorageDriver,
TaskExecution,
TaskExecutionStatus
} from "@/lib/services/admin/types"
const storageConfigKey = "storage_config"
const storageMigrationTaskType = "storage_migration"
const driverLabels: Record<StorageDriver, string> = {
local: "本地文件系统",
s3: "AWS S3",
r2: "Cloudflare R2",
minio: "MinIO",
oss: "阿里云 OSS",
webdav: "WebDAV",
}
const emptyObjectConfig: ObjectStorageConfig = {
endpoint: "",
region: "",
bucket: "",
access_key_id: "",
secret_access_key: "",
path_style: false,
key_prefix: "",
cdn_url: "",
}
function normalizeConfig(config: StorageConfig): StorageConfig {
const s3 = {...emptyObjectConfig, ...config.s3}
const r2 = {...emptyObjectConfig, ...config.r2}
const minio = {...emptyObjectConfig, ...config.minio}
const oss = {...emptyObjectConfig, ...config.oss}
const webdav = {...config.webdav}
return {
...config,
local: {root: config.local?.root || "."},
s3: {...s3, region: s3.region || "us-east-1"},
r2: {...r2, region: r2.region || "auto"},
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
oss,
webdav: {
endpoint: webdav.endpoint || "",
username: webdav.username || "",
password: webdav.password || "",
base_path: webdav.base_path || "",
},
}
}
type StorageMigrationView = {
state: "idle" | TaskExecutionStatus
id?: string
task_id?: string
source_driver?: StorageDriver
target_driver?: StorageDriver
error?: string
}
type StorageMigrationPayload = {
target: StorageConfig
}
function parseMigrationPayload(payload: string): StorageConfig | undefined {
try {
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
return parsed.target ? normalizeConfig(parsed.target) : undefined
} catch {
return undefined
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : "未知"
}
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
const execution = executions[0]
if (!execution || execution.status === "succeeded") {
return {state: "idle"}
}
const target = parseMigrationPayload(execution.payload)
return {
state: execution.status,
id: execution.id,
task_id: execution.task_id,
source_driver: current?.driver,
target_driver: target?.driver,
error: execution.error_message,
}
}
export function StorageConfigTab() {
const queryClient = useQueryClient()
const [config, setConfig] = React.useState<StorageConfig | null>(null)
const query = useQuery({
queryKey: ["admin", "storage-config"],
queryFn: async () => {
const [configRecord, executions] = await Promise.all([
services.adminSystemConfig.getSystemConfig(storageConfigKey),
services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
])
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
const migration = latestMigration(executions.items, current)
return {
config: current,
migration,
}
},
refetchInterval: (result) => {
const state = result.state.data?.migration.state
return state === "pending" || state === "running" ? 2000 : false
},
})
React.useEffect(() => {
if (query.data?.config) {
setConfig((current) => {
const normalized = normalizeConfig(query.data.config)
if (current) {
return {
...normalized,
driver: current.driver,
}
}
return normalized
})
}
}, [query.data?.config])
const saveMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
await services.adminSystemConfig.updateSystemConfig(storageConfigKey, {
value: JSON.stringify(value),
})
},
onSuccess: () => {
toast.success("存储配置已保存")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => {
toast.error(error.message || "保存存储配置失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
})
const migrateMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
const payload: StorageMigrationPayload = {target: value}
return services.adminTask.dispatchTask({
task_type: storageMigrationTaskType,
payload: JSON.stringify(payload),
})
},
onSuccess: () => {
toast.success("存储迁移任务已下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => {
toast.error(error.message || "下发存储迁移任务失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
})
const runMutation = useMutation({
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success("存储迁移任务已重新下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
},
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
})
if (query.isPending || !config) {
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
}
const migration = query.data?.migration
const isReadOnly = migration ? migration.state !== "idle" : false
const isFormDisabled =
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
saveMutation.isPending ||
migrateMutation.isPending
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
setConfig((current) => current ? {
...current,
[driver]: {...current[driver], ...patch},
} : current)
}
return (
<div className="flex w-full flex-col gap-6">
{isReadOnly && migration && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
存储维护模式
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
{migration.state}
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
<p className="text-sm text-muted-foreground">
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
</p>
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
</CardContent>
<CardFooter>
<Button
variant="outline"
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
onClick={() => migration.id && runMutation.mutate(migration.id)}
>
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
重试迁移
</Button>
</CardFooter>
</Card>
)}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
<Database className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
<CardDescription className="text-xs">
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<FieldGroup className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field className="flex flex-col gap-1.5 md:col-span-2">
<FieldLabel className="text-xs font-semibold">存储类型</FieldLabel>
<Select
value={config.driver}
disabled={isFormDisabled}
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
>
<SelectTrigger className="w-full border-dashed bg-card text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{config.driver === "local" && (
<div className="md:col-span-2">
<TextField
label="根目录"
value={config.local.root}
placeholder="."
onChange={(root) => setConfig({...config, local: {root}})}
/>
</div>
)}
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
<ObjectFields
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
/>
)}
{config.driver === "webdav" && (
<>
<div className="md:col-span-2">
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
</div>
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
<div className="md:col-span-2">
<TextField label="基础路径" value={config.webdav.base_path} placeholder="wavelet" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
</div>
</>
)}
</div>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
{config.driver !== query.data?.config.driver && (
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
</span>
)}
<div className="flex gap-2">
<Button
variant="outline"
disabled={isFormDisabled}
onClick={() => saveMutation.mutate(config)}
className="border-dashed"
>
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
保存配置
</Button>
{config.driver !== query.data?.config.driver && (
<Button
disabled={isFormDisabled}
onClick={() => migrateMutation.mutate(config)}
>
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
开始迁移
</Button>
)}
</div>
</CardFooter>
</Card>
</div>
)
}
function ObjectFields({
driver,
value,
onChange,
}: {
driver: "s3" | "r2" | "minio" | "oss"
value: ObjectStorageConfig
onChange: (patch: Partial<ObjectStorageConfig>) => void
}) {
return (
<>
{driver === "r2" && (
<div className="md:col-span-2">
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
</div>
)}
{driver !== "s3" && (
<div className="md:col-span-2">
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
</div>
)}
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
{(driver === "s3" || driver === "minio") && (
<div className="md:col-span-2">
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
<div className="flex flex-col gap-0.5">
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
</div>
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
</Field>
</div>
)}
</>
)
}
function TextField({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: React.HTMLInputTypeAttribute
}) {
return (
<Field className="flex flex-col gap-1.5">
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
<Input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-dashed bg-card text-xs"
/>
</Field>
)
}
@@ -0,0 +1,59 @@
"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/file-stats"
import {FileList} from "./components/file-list"
import {StorageConfigTab} from "./components/storage-config-tab"
export default function FilesPage() {
const [activeTab, setActiveTab] = React.useState("stats")
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">
{activeTab === "stats" ? <FileStats /> : null}
</TabsContent>
{/* ──────── TAB 2: 文件列表 ──────── */}
<TabsContent value="list" className="outline-hidden">
{activeTab === "list" ? <FileList /> : null}
</TabsContent>
<TabsContent value="storage" className="outline-hidden">
{activeTab === "storage" ? <StorageConfigTab /> : null}
</TabsContent>
</Tabs>
</motion.div>
)
}
@@ -0,0 +1,18 @@
"use client"
import {AdminUsersProvider} from "@/contexts/admin-users-context"
import {RequireAdminAuth} from "@/components/auth/require-auth"
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<RequireAdminAuth>
<AdminUsersProvider>
{children}
</AdminUsersProvider>
</RequireAdminAuth>
)
}
@@ -0,0 +1,736 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {ChevronDown, Edit2, Layers, Loader2, Plus, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Checkbox} from "@/components/ui/checkbox"
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import services from "@/lib/services"
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
import {PushService} from "@/lib/services/push"
export function EventsTab() {
const queryClient = useQueryClient()
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => PushService.listChannels(),
})
const availableChannels = React.useMemo(() => {
const customChannels = (channelsQuery.data ?? [])
.filter(c => c.enabled)
.map(c => c.name)
return ["email", ...customChannels]
}, [channelsQuery.data])
// --- 获取通知事件 ---
const eventsQuery = useQuery({
queryKey: ["admin", "push-events"],
queryFn: () => PushService.listEvents(),
})
const builtInEventsQuery = useQuery({
queryKey: ["admin", "push-builtin-events"],
queryFn: () => PushService.listBuiltInEvents(),
})
// --- 获取所有系统可调度任务类型 ---
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.adminTask.getTaskTypes(),
})
// --- 修改保存事件 Mutation ---
const updateEventMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
onSuccess: () => {
toast.success("事件更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setEditEventOpen(false)
},
onError: (err: unknown) => {
toast.error("事件更新失败: " + (err as Error).message)
},
})
const toggleEventMutation = useMutation({
mutationFn: (id: number) => PushService.toggleEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
onError: (err: unknown) => {
toast.error("操作失败: " + (err as Error).message)
},
})
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success("事件创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setCreateEventOpen(false)
setNewEventKey("")
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventChannels([])
setNewEventEnabled(true)
},
onError: (err: unknown) => {
toast.error("事件创建失败: " + (err as Error).message)
},
})
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
toast.success("配置删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
onError: (err: unknown) => {
toast.error("配置删除失败: " + (err as Error).message)
},
})
// --- 事件编辑对话框状态 ---
const [editEventOpen, setEditEventOpen] = React.useState(false)
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
const [eventChannels, setEventChannels] = React.useState<string[]>([])
const [eventTargets, setEventTargets] = React.useState("")
const [eventTemplate, setEventTemplate] = React.useState("")
// 事件创建对话框状态
const [createEventOpen, setCreateEventOpen] = React.useState(false)
const [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
const [newEventKey, setNewEventKey] = React.useState("")
const [newEventTaskType, setNewEventTaskType] = React.useState("")
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
const [newEventTargets, setNewEventTargets] = React.useState("")
const [newEventTemplate, setNewEventTemplate] = React.useState("")
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
const availableBuiltInEvents = React.useMemo(() => {
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
}, [builtInEventsQuery.data, eventsQuery.data])
const handleEditEventClick = (event: PushEvent) => {
setSelectedEvent(event)
setEventChannels(event.channels)
setEventTargets((event.targets ?? []).join(", "))
setEventTemplate(event.template)
setEditEventOpen(true)
}
const handleSaveEvent = () => {
if (!selectedEvent) return
try {
JSON.parse(eventTemplate)
} catch {
toast.error("消息模板不是合法的 JSON 格式")
return
}
const targets = eventTargets
.split(",")
.map(t => t.trim())
.filter(t => t !== "")
updateEventMutation.mutate({
id: selectedEvent.id,
data: {
channels: eventChannels,
targets,
template: eventTemplate,
enabled: selectedEvent.enabled,
},
})
}
const handleCreateEventClick = () => {
setNewEventKey("")
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventChannels([])
setNewEventTargets("")
setNewEventTemplate("")
setNewEventEnabled(true)
setCreateEventOpen(true)
}
const handleNewEventKeyChange = (key: string) => {
setNewEventKey(key)
const ev = availableBuiltInEvents.find(e => e.key === key)
if (ev) {
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
} else {
setNewEventTemplate("")
}
}
const handleNewEventTaskTypeChange = (taskType: string) => {
setNewEventTaskType(taskType)
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
level: "INFO",
}
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
} else {
setNewEventTemplate("")
}
}
const handleCreateEvent = () => {
if (newEventType === "builtin" && !newEventKey) {
toast.error("请选择系统事件")
return
}
if (newEventType === "task" && !newEventTaskType) {
toast.error("请选择异步任务")
return
}
if (newEventTemplate) {
try {
JSON.parse(newEventTemplate)
} catch {
toast.error("内容渲染模板不是合法的 JSON 格式")
return
}
}
const targets = newEventTargets
.split(",")
.map(t => t.trim())
.filter(t => t !== "")
createEventMutation.mutate({
event_key: newEventType === "builtin" ? newEventKey : undefined,
task_type: newEventType === "task" ? newEventTaskType : undefined,
channels: newEventChannels,
targets: targets.length > 0 ? targets : undefined,
template: newEventTemplate || undefined,
enabled: newEventEnabled,
})
}
return (
<div className="pt-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
<Plus className="size-3.5 mr-1" />
新增通知事件
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
) : eventsQuery.isError ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
</div>
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder icon={Layers} 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-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
<TableHead className="w-[80px] text-center whitespace-nowrap 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>
{(eventsQuery.data ?? []).map((event) => (
<TableRow
key={event.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleEditEventClick(event)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
<TableCell className="py-1">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
{event.task_type && (
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
任务
</Badge>
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
</div>
</TableCell>
<TableCell className="py-1">
<div className="flex flex-wrap gap-1">
{(event.channels ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
) : (
event.channels.map(ch => (
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
{ch === "email" ? "邮件" : ch}
</Badge>
))
)}
</div>
</TableCell>
<TableCell className="py-1">
<div className="flex flex-wrap gap-1">
{(!event.targets || event.targets.length === 0) ? (
<span className="text-muted-foreground text-[10px] font-mono">-</span>
) : (
event.targets.map(t => (
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
{t}
</Badge>
))
)}
</div>
</TableCell>
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
<Switch
checked={event.enabled}
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</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>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleEditEventClick(event)}
>
<Edit2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
配置
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm("确定要删除该通知事件配置吗?")) {
deleteEventMutation.mutate(event.id)
}
}}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* ==================== 对话框:新增事件 ==================== */}
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">事件类型</Label>
<div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<input
type="radio"
name="eventType"
checked={newEventType === "builtin"}
onChange={() => {
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventTemplate("")
}}
className="scale-90"
/>
<span>系统内置事件</span>
</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<input
type="radio"
name="eventType"
checked={newEventType === "task"}
onChange={() => {
setNewEventType("task")
setNewEventKey("")
setNewEventTemplate("")
}}
className="scale-90"
/>
<span>任务完成事件</span>
</label>
</div>
</div>
{newEventType === "builtin" ? (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">系统事件</Label>
{builtInEventsQuery.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>加载系统事件中...</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
所有内置事件都已配置,没有可新增的事件。
</div>
) : (
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="请选择系统事件" />
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map(ev => (
<SelectItem key={ev.key} value={ev.key} className="text-xs">
{ev.name} ({ev.key})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
) : (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">系统异步任务</Label>
{taskTypesQuery.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>加载异步任务中...</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
暂无可用的系统任务。
</div>
) : (
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="请选择异步任务" />
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map(taskMeta => (
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
{taskMeta.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{newEventType === "builtin" && newEventKey && (
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
<span className="font-semibold text-foreground">事件说明:</span>
<span>
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
</span>
</div>
)}
{newEventType === "task" && newEventTaskType && (
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
<span className="font-semibold text-foreground">任务说明:</span>
<span>
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.description || "无描述信息"}
</span>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
{newEventChannels.length > 0
? newEventChannels
.map(ch => {
if (ch === "email") return "邮件"
return ch
})
.join(", ")
: "选择已配置推送渠道"}
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{availableChannels.map(ch => (
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
<Checkbox
checked={newEventChannels.includes(ch)}
onCheckedChange={checked => {
if (checked) {
setNewEventChannels([...newEventChannels, ch])
} else {
setNewEventChannels(newEventChannels.filter(c => c !== ch))
}
}}
/>
<span>
{ch === "email" ? "邮件推送" : ch}
</span>
</label>
))}
{availableChannels.length === 0 && (
<div className="text-[11px] text-muted-foreground italic p-1">
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
<Input
type="text"
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
value={newEventTargets}
onChange={e => setNewEventTargets(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
{newEventType === "task"
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
</span>
</div>
<Textarea
value={newEventTemplate}
onChange={e => setNewEventTemplate(e.target.value)}
rows={6}
className="text-xs font-mono"
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
<div className="space-y-0.5">
<Label className="text-xs font-semibold">启用状态</Label>
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
</div>
<Switch
checked={newEventEnabled}
onCheckedChange={setNewEventEnabled}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={createEventMutation.isPending || (newEventType === "builtin" && availableBuiltInEvents.length === 0)}
onClick={handleCreateEvent}
className="h-9 px-5 text-xs"
>
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
启用保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ==================== 对话框:编辑事件 ==================== */}
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-5 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
</div>
</div>
{selectedEvent.task_type && (
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
disabled
className="text-xs h-9 bg-muted"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
{eventChannels.length > 0
? eventChannels
.map(ch => {
if (ch === "email") return "邮件"
return ch
})
.join(", ")
: "选择已配置推送渠道"}
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{availableChannels.map(ch => (
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
<Checkbox
checked={eventChannels.includes(ch)}
onCheckedChange={checked => {
if (checked) {
setEventChannels([...eventChannels, ch])
} else {
setEventChannels(eventChannels.filter(c => c !== ch))
}
}}
/>
<span>
{ch === "email" ? "邮件推送" : ch}
</span>
</label>
))}
{availableChannels.length === 0 && (
<div className="text-[11px] text-muted-foreground italic p-1">
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
<Input
type="text"
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
value={eventTargets}
onChange={e => setEventTargets(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
{selectedEvent.task_type
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
</span>
</div>
<Textarea
value={eventTemplate}
onChange={e => setEventTemplate(e.target.value)}
rows={6}
className="text-xs font-mono"
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={updateEventMutation.isPending}
onClick={handleSaveEvent}
className="h-9 px-5 text-xs"
>
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
保存修改
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,312 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useQuery} from "@tanstack/react-query"
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {cn} from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type {PushHistory} from "@/lib/services/push"
import {PushService} from "@/lib/services/push"
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
switch (level) {
case "CRITICAL":
return "destructive"
case "IMPORTANT":
return "default"
default:
return "outline"
}
}
export function HistoriesTab() {
const [historyPage, setHistoryPage] = React.useState(1)
const [historySearch, setHistorySearch] = React.useState("")
const [historyStatus, setHistoryStatus] = React.useState("all")
const [detailOpen, setDetailOpen] = React.useState(false)
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
const handleRowClick = (hist: PushHistory) => {
setSelectedHistory(hist)
setDetailOpen(true)
}
const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryFn: () =>
PushService.listHistories({
page: historyPage,
page_size: 10,
event_key: historySearch || undefined,
status: historyStatus === "all" ? undefined : historyStatus,
}),
})
return (
<div className="pt-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="输入事件键过滤..."
className="pl-8 text-xs h-9"
value={historySearch}
onChange={e => {
setHistorySearch(e.target.value)
setHistoryPage(1)
}}
/>
</div>
<div className="w-[150px]">
<Select
value={historyStatus}
onValueChange={val => {
setHistoryStatus(val)
setHistoryPage(1)
}}
>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="发送状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => historiesQuery.refetch()}
>
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="text-xs font-semibold">事件</TableHead>
<TableHead className="text-xs font-semibold">渠道</TableHead>
<TableHead className="text-xs font-semibold">目标</TableHead>
<TableHead className="text-xs font-semibold">标题</TableHead>
<TableHead className="text-xs font-semibold">等级</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
<TableHead className="text-xs font-semibold">时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historiesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
</TableCell>
</TableRow>
) : historiesQuery.isError ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
</TableCell>
</TableRow>
) : (historiesQuery.data?.results ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
无推送历史数据
</TableCell>
</TableRow>
) : (
(historiesQuery.data?.results ?? []).map(hist => (
<TableRow
key={hist.id}
className="hover:bg-muted/10 cursor-pointer transition-colors"
onClick={() => handleRowClick(hist)}
>
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
{hist.channel === "email" ? "邮件" : hist.channel}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
{hist.target}
</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
<TableCell className="text-xs">
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
{hist.level}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Badge
variant={hist.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold"
>
{hist.status === "success" ? "成功" : "失败"}
</Badge>
{hist.status !== "success" && hist.error_msg && (
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
{hist.error_msg}
</div>
)}
</TableCell>
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(hist.created_at).toLocaleString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>共 {historiesQuery.data.total} 条历史记录</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
disabled={historyPage === 1}
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
className="h-8 w-8"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs font-medium px-2">{historyPage}</span>
<Button
variant="outline"
size="icon"
disabled={historyPage * 10 >= historiesQuery.data.total}
onClick={() => setHistoryPage(p => p + 1)}
className="h-8 w-8"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
{/* ==================== 对话框:推送详情 ==================== */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="size-5 text-primary" />
推送通知详情
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
</DialogDescription>
</DialogHeader>
{selectedHistory && (
<div className="space-y-4 py-4 text-xs">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">事件标识</span>
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送渠道</span>
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">推送目标</span>
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
{selectedHistory.target}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送时间</span>
<div className="bg-muted/40 p-2 rounded border">
{new Date(selectedHistory.created_at).toLocaleString()}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知等级</span>
<div>
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
{selectedHistory.level}
</Badge>
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送状态</span>
<div>
<Badge
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold py-0.5 px-2"
>
{selectedHistory.status === "success" ? "成功" : "失败"}
</Badge>
</div>
</div>
</div>
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
<div className="space-y-1.5">
<span className="font-semibold text-destructive block">失败原因</span>
<div className="font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all">
{selectedHistory.error_msg}
</div>
</div>
)}
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知标题</span>
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
{selectedHistory.title}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知内容</span>
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
{selectedHistory.content}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,632 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {Edit2, Loader2, Play, Plus, Settings, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
import {PushService} from "@/lib/services/push"
export function SettingsTab() {
const queryClient = useQueryClient()
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => PushService.listChannels(),
})
// --- 获取动态通道表单字段定义 ---
const definitionsQuery = useQuery({
queryKey: ["admin", "push-channels-definitions"],
queryFn: () => PushService.listChannelDefinitions(),
})
// --- 消息通道 CRUD Mutations ---
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success("通道创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
setChannelDialogOpen(false)
},
onError: (err: unknown) => {
toast.error("通道创建失败: " + (err as Error).message)
},
})
const updateChannelMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success("通道更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
setChannelDialogOpen(false)
},
onError: (err: unknown) => {
toast.error("通道更新失败: " + (err as Error).message)
},
})
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
toast.success("通道删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
},
onError: (err: unknown) => {
toast.error("通道删除失败: " + (err as Error).message)
},
})
// --- 消息通道与设置相关 State ---
const [channelDialogOpen, setChannelDialogOpen] = React.useState(false)
const [editingChannel, setEditingChannel] = React.useState<PushChannel | null>(null)
const [channelName, setChannelName] = React.useState("")
const [channelDescription, setChannelDescription] = React.useState("")
const [channelType, setChannelType] = React.useState("custom")
const [channelToken, setChannelToken] = React.useState("")
const [channelUrl, setChannelUrl] = React.useState("")
const [channelOther, setChannelOther] = React.useState("")
const activeDef = React.useMemo<ChannelDefinition | undefined>(() => {
return (definitionsQuery.data ?? []).find(d => d.type === channelType)
}, [definitionsQuery.data, channelType])
const [testChannelOpen, setTestChannelOpen] = React.useState(false)
const [testChannelName, setTestChannelName] = React.useState("")
const [testChannelTarget, setTestChannelTarget] = React.useState("")
const [isTestingChannel, setIsTestingChannel] = React.useState(false)
const handleChannelTypeChange = (newType: string) => {
setChannelType(newType)
setChannelUrl("")
setChannelToken("")
if (newType === "custom") {
setChannelOther(JSON.stringify({
title: "$title",
description: "$description",
content: "$content",
url: "$url",
to: "$to"
}, null, 2))
} else {
setChannelOther("")
}
}
const handleCreateChannelClick = () => {
setEditingChannel(null)
setChannelName("")
setChannelDescription("")
setChannelType("custom")
setChannelToken("")
setChannelUrl("")
setChannelOther(JSON.stringify({
title: "$title",
description: "$description",
content: "$content",
url: "$url",
to: "$to"
}, null, 2))
setChannelDialogOpen(true)
}
const handleEditChannelClick = (channel: PushChannel) => {
setEditingChannel(channel)
setChannelName(channel.name)
setChannelDescription(channel.description ?? "")
setChannelType(channel.type)
setChannelToken(channel.token ?? "")
setChannelUrl(channel.url)
setChannelOther(channel.other)
setChannelDialogOpen(true)
}
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error("通道名称不能为空")
return
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error("通道名称只能使用英文字母、数字和下划线")
return
}
if (!activeDef) {
toast.error("无效的通道类型")
return
}
// 动态字段必填性校验
for (const field of activeDef.fields) {
const value = field.key === "url"
? channelUrl
: field.key === "token"
? channelToken
: channelOther;
if (field.required && !value.trim()) {
toast.error(`${field.label}不能为空`)
return
}
}
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== "email") {
if (channelUrl && !channelUrl.startsWith("https://")) {
toast.error("地址必须以 https:// 开头以确保安全性")
return
}
}
// JSON 结构格式校验
if (channelType === "custom") {
try {
JSON.parse(channelOther)
} catch {
toast.error("请求体必须是合法的 JSON 格式")
return
}
} else if (channelType === "lark" && channelOther) {
try {
JSON.parse(channelOther)
} catch {
toast.error("自定义卡片模版必须是合法的 JSON 格式")
return
}
}
if (editingChannel) {
updateChannelMutation.mutate({
id: editingChannel.id,
data: {
description: channelDescription,
type: channelType,
token: channelToken || undefined,
url: channelUrl,
other: channelOther,
enabled: editingChannel.enabled,
}
})
} else {
createChannelMutation.mutate({
name: channelName,
description: channelDescription,
type: channelType,
token: channelToken || undefined,
url: channelUrl,
other: channelOther,
enabled: true,
})
}
}
const handleTestChannelClick = (name: string) => {
setTestChannelName(name)
setTestChannelTarget("")
setTestChannelOpen(true)
}
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true)
toast.info("正在发送测试推送...")
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
})
toast.success("测试推送发送成功,请前往对应平台确认。")
setTestChannelOpen(false)
} catch (err: unknown) {
toast.error("连通性测试失败: " + (err as Error).message)
} finally {
setIsTestingChannel(false)
}
}
return (
<div className="pt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">自定义推送通道</h2>
<p className="text-[11px] text-muted-foreground mt-0.5">
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
</p>
</div>
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
<Plus className="size-3.5 mr-1" />
新建消息通道
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." />
) : channelsQuery.isError ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
</div>
) : (channelsQuery.data ?? []).length === 0 ? (
<div className="py-12 border border-dashed rounded-lg flex flex-col items-center justify-center text-muted-foreground">
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
</div>
) : (
<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-[120px] whitespace-nowrap py-2 h-8">名称</TableHead>
<TableHead className="w-[100px] whitespace-nowrap py-2 h-8">类型</TableHead>
<TableHead className="whitespace-nowrap py-2 h-8">备注</TableHead>
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(channelsQuery.data ?? []).map(ch => (
<TableRow
key={ch.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleEditChannelClick(ch)}
>
<TableCell className="text-xs font-mono font-bold py-1">
{ch.name}
</TableCell>
<TableCell className="py-1">
<Badge variant="outline" className="text-[10px] py-0 px-1.5 h-4.5 whitespace-nowrap">
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate py-1">
{ch.description || <span className="italic">无备注</span>}
</TableCell>
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
<Switch
checked={ch.enabled}
onCheckedChange={checked => {
updateChannelMutation.mutate({
id: ch.id,
data: {
description: ch.description,
type: ch.type,
token: ch.token,
url: ch.url,
other: ch.other,
enabled: checked,
}
})
}}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</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-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestChannelClick(ch.name)}
className="h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10"
>
<Play className="size-2.5 mr-1" />
测试
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditChannelClick(ch)}
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
>
<Edit2 className="size-2.5 mr-1" />
编辑
</Button>
<Button
variant="ghost"
size="sm"
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
deleteChannelMutation.mutate(ch.id)
}
}}
className="h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-2.5 mr-1" />
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">名称</Label>
<Input
type="text"
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
value={channelName}
onChange={e => setChannelName(e.target.value)}
disabled={!!editingChannel}
className="text-xs h-9 font-mono"
/>
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">备注</Label>
<Input
type="text"
placeholder="请输入备注信息"
value={channelDescription}
onChange={e => setChannelDescription(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">通道类型</Label>
<Select value={channelType} onValueChange={handleChannelTypeChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="选择通道类型" />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map(d => (
<SelectItem key={d.type} value={d.type} className="text-xs">
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeDef && (
<>
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{activeDef.description}
</p>
</div>
{activeDef.fields.map(field => {
const value = field.key === "url"
? channelUrl
: field.key === "token"
? channelToken
: channelOther;
const onChange = (val: string) => {
if (field.key === "url") setChannelUrl(val);
else if (field.key === "token") setChannelToken(val);
else setChannelOther(val);
};
return (
<div key={field.key} className="space-y-1.5">
<Label className="text-xs font-semibold">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{field.type === "textarea" ? (
<Textarea
placeholder={field.placeholder}
value={value}
onChange={e => onChange(e.target.value)}
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
className="text-xs font-mono"
/>
) : (
<Input
type={field.type}
placeholder={field.placeholder}
value={value}
onChange={e => onChange(e.target.value)}
className="text-xs h-9 font-mono"
/>
)}
{field.description && (
<p className="text-[10px] text-muted-foreground">
{field.description}
</p>
)}
</div>
);
})}
{/* Custom post helper templates card */}
{channelType === "custom" && (
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
<div className="flex flex-wrap gap-1.5">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
setChannelOther(JSON.stringify({
msg_type: "text",
content: {
text: "$title\n$description\n$content\n$url"
}
}, null, 2));
toast.success("已加载飞书 Webhook 模版");
}}
>
飞书 Webhook
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
setChannelOther(JSON.stringify({
msgtype: "markdown",
markdown: {
title: "$title",
text: "### $title\n$content\n\n[查看详情]($url)"
}
}, null, 2));
toast.success("已加载钉钉机器人模版");
}}
>
钉钉群机器人
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
setChannelOther(JSON.stringify({
msgtype: "markdown",
markdown: {
content: "### $title\n$content\n\n[查看详情]($url)"
}
}, null, 2));
toast.success("已加载企业微信机器人模版");
}}
>
企业微信群机器人
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://api.day.app/push");
setChannelOther(JSON.stringify({
device_key: "$to",
title: "$title",
body: "$content",
url: "$url"
}, null, 2));
toast.success("已加载 Bark App 模版");
}}
>
Bark App
</Button>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
onClick={handleSaveChannel}
className="h-9 px-5 text-xs"
>
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
确定
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ==================== 对话框:测试渠道连通性 ==================== */}
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-3">
<div className="space-y-1.5">
<Label className="text-xs">推送通道名称</Label>
<Input
type="text"
value={testChannelName}
disabled
className="text-xs h-9 bg-muted font-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
<Input
type="text"
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
value={testChannelTarget}
onChange={e => setTestChannelTarget(e.target.value)}
className="text-xs h-9"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={isTestingChannel}
onClick={handleSendChannelTest}
className="h-9 px-5 text-xs"
>
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
发送测试
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,65 @@
// 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 "./components/events-tab"
import {HistoriesTab} from "./components/histories-tab"
import {SettingsTab} from "./components/settings-tab"
export default function PushAdminPage() {
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>
)
}
@@ -0,0 +1,338 @@
"use client"
import * as React from "react"
import {useCallback, useEffect, useRef, useState} from "react"
import {toast} from "sonner"
import {Activity, Cpu, Database, HardDrive, Layers, RefreshCw} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Badge} from "@/components/ui/badge"
import {Progress} from "@/components/ui/progress"
import {Skeleton} from "@/components/ui/skeleton"
import type {SystemStatus} from "@/lib/services/admin"
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, ",")
}
/**
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const [status, setStatus] = useState<SystemStatus | null>(null)
const [loading, setLoading] = useState(true)
const [wavelet, setWavelet] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const prevStatusRef = useRef<SystemStatus | null>(null)
const [changedFields, setChangedFields] = useState<Record<string, boolean>>({})
// 获取状态数据
const fetchStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setWavelet(true)
try {
const data = await services.adminStatus.getSystemStatus()
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {}
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true
}
})
setChangedFields(changes)
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({})
}, 1000)
}
setStatus(data)
prevStatusRef.current = data
} catch (err) {
toast.error("获取系统状态失败", {
description: err instanceof Error ? err.message : "请求时发生未知错误"
})
} finally {
setLoading(false)
setWavelet(false)
}
}, [])
// 轮询逻辑
useEffect(() => {
// 首次加载
fetchStatus()
}, [fetchStatus])
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
if (autoRefresh) {
const time = parseInt(intervalTime, 10)
intervalRef.current = setInterval(() => {
fetchStatus(true)
}, time)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [autoRefresh, intervalTime, fetchStatus])
// 计算内存分配百分比 (Alloc / Sys)
const getMemoryUsagePercent = () => {
if (!status) return 0
const parseSizeToBytes = (sizeStr: string): number => {
const parts = sizeStr.split(" ")
if (parts.length < 2) return 0
const value = parseFloat(parts[0])
const unit = parts[1].toLowerCase()
if (unit.startsWith("gib")) return value * 1024 * 1024 * 1024
if (unit.startsWith("mib")) return value * 1024 * 1024
if (unit.startsWith("kib")) return value * 1024
return value
}
const alloc = parseSizeToBytes(status.alloc)
const sys = parseSizeToBytes(status.sys)
if (sys === 0) return 0
return Math.min(Math.round((alloc / sys) * 100), 100)
}
// 闪烁动画类
const getPulseClass = (field: string) => {
return changedFields[field] ? "animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block" : "transition-all duration-300"
}
const RenderMetricRow = ({ label, value, field }: { label: string; value: string | number; field?: string }) => (
<div className="flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ""}`}>
{typeof value === "number" ? formatNumber(value) : value}
</span>
</div>
)
if (loading) {
return (
<div className="space-y-6 p-1">
<div className="flex items-center justify-between border-b border-border/40 pb-4">
<div className="space-y-1">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-8" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
)
}
return (
<div className="py-6 px-1 space-y-6">
{/* 顶部控制栏 */}
<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">
<div className="flex items-center gap-2 px-1">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
自动刷新
</label>
</div>
{autoRefresh && (
<Select value={intervalTime} onValueChange={setIntervalTime}>
<SelectTrigger className="h-7 w-20 text-[11px] bg-background border-border/40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1000">1s</SelectItem>
<SelectItem value="2000">2s</SelectItem>
<SelectItem value="5000">5s</SelectItem>
<SelectItem value="10000">10s</SelectItem>
</SelectContent>
</Select>
)}
<Button
size="sm"
variant="secondary"
className="h-7 text-[11px] gap-1.5"
onClick={() => fetchStatus()}
disabled={wavelet}
>
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
刷新
</Button>
</div>
</div>
{status && (
<>
{/* 主网格排版 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* 1. 服务概览 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">服务概览</CardTitle>
<CardDescription className="text-[10px]">服务的基础生命指标</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Activity className="size-4" />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="服务运行时间" value={status.uptime} field="uptime" />
<RenderMetricRow label="当前 Goroutines 数量" value={status.num_goroutine} field="num_goroutine" />
<RenderMetricRow label="Heap 对象数量" value={status.heap_objects} field="heap_objects" />
</CardContent>
</Card>
{/* 2. 内存统计 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">内存统计</CardTitle>
<CardDescription className="text-[10px]">主内存消耗概览</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Cpu className="size-4" />
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<RenderMetricRow label="当前内存使用量" value={status.alloc} field="alloc" />
<RenderMetricRow label="所有已分配的内存" value={status.total_alloc} field="total_alloc" />
<RenderMetricRow label="内存占用量" value={status.sys} field="sys" />
<RenderMetricRow label="下次 GC 内存回收量" value={status.next_gc} field="next_gc" />
</div>
{/* 物理内存水位比例 */}
<div className="space-y-1 px-1 pt-1">
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>当前使用率 (Alloc / Sys)</span>
<span className="font-mono">{getMemoryUsagePercent()}%</span>
</div>
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
</div>
</CardContent>
</Card>
{/* 3. 堆/栈详情 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">堆/栈详情</CardTitle>
<CardDescription className="text-[10px]">运行时堆栈内存空间细节</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Database className="size-4" />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="当前 Heap 内存使用量" value={status.heap_alloc} field="heap_alloc" />
<RenderMetricRow label="Heap 内存占用量" value={status.heap_sys} field="heap_sys" />
<RenderMetricRow label="Heap 内存空闲量" value={status.heap_idle} field="heap_idle" />
<RenderMetricRow label="正在使用的 Heap 内存" value={status.heap_inuse} field="heap_inuse" />
<RenderMetricRow label="已释放的 Heap 内存" value={status.heap_released} field="heap_released" />
<RenderMetricRow label="启动 Stack 使用量" value={status.stack_inuse} field="stack_inuse" />
<RenderMetricRow label="已分配的 Stack 内存" value={status.stack_sys} field="stack_sys" />
</CardContent>
</Card>
{/* 4. 底层组件与结构体 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">底层及结构体内存</CardTitle>
<CardDescription className="text-[10px]">运行时底层管理结构体开销</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<HardDrive className="size-4" />
</Badge>
</CardHeader>
<CardContent className="space-y-1">
<RenderMetricRow label="MSpan 结构内存使用量" value={status.mspan_inuse} field="mspan_inuse" />
<RenderMetricRow label="已分配的 MSpan 结构内存" value={status.mspan_sys} field="mspan_sys" />
<RenderMetricRow label="MCache 结构内存使用量" value={status.mcache_inuse} field="mcache_inuse" />
<RenderMetricRow label="已分配的 MCache 结构内存" value={status.mcache_sys} field="mcache_sys" />
<RenderMetricRow label="已分配的剖析哈希表内存" value={status.buck_hash_sys} field="buck_hash_sys" />
<RenderMetricRow label="已分配的 GC 元数据内存" value={status.gc_sys} field="gc_sys" />
<RenderMetricRow label="其它已分配的系统内存" value={status.other_sys} field="other_sys" />
</CardContent>
</Card>
{/* 5. 垃圾回收与分配计数 */}
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">垃圾回收与分配计数</CardTitle>
<CardDescription className="text-[10px]">GC 历史数据与分配频次</CardDescription>
</div>
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
<Layers className="size-4" />
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
<div>
<RenderMetricRow label="GC 执行次数" value={status.num_gc} field="num_gc" />
<RenderMetricRow label="距离上次 GC 时间" value={status.last_gc_time} field="last_gc_time" />
<RenderMetricRow label="GC 暂停时间总量" value={status.pause_total_ns} field="pause_total_ns" />
<RenderMetricRow label="上次 GC 暂停时间" value={status.last_pause} field="last_pause" />
</div>
<div>
<RenderMetricRow label="内存分配次数" value={status.mallocs} field="mallocs" />
<RenderMetricRow label="内存释放次数" value={status.frees} field="frees" />
<RenderMetricRow label="指针查找次数" value={status.lookups} field="lookups" />
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,144 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import dynamic from "next/dynamic"
import {useEffect, useMemo} from "react"
import {useQuery} from "@tanstack/react-query"
import {Loader2, Settings} from "lucide-react"
import {useRouter} from "next/navigation"
import {motion} from "motion/react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {useAuth} from "@/components/providers/auth-provider"
import services from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
const tabFallback = (
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
)
const SecurityTab = dynamic(
() => import("@/components/common/settings/security-tab").then((mod) => mod.SecurityTab),
{ loading: () => tabFallback },
)
const OperationTab = dynamic(
() => import("@/components/common/settings/operation-tab").then((mod) => mod.OperationTab),
{ loading: () => tabFallback },
)
const SystemTab = dynamic(
() => import("@/components/common/settings/system-tab").then((mod) => mod.SystemTab),
{ loading: () => tabFallback },
)
const OtherTab = dynamic(
() => import("@/components/common/settings/other-tab").then((mod) => mod.OtherTab),
{ loading: () => tabFallback },
)
const InfoTab = dynamic(
() => import("@/components/common/settings/info-tab").then((mod) => mod.InfoTab),
{ loading: () => tabFallback },
)
const SystemStatusManager = dynamic(
() => import("./components/system-status").then((mod) => mod.SystemStatusManager),
{ loading: () => tabFallback },
)
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config
return accumulator
}, {})
}
export function AdminSettingsPageClient() {
const { user, loading } = useAuth()
const router = useRouter()
const systemConfigsQuery = useQuery({
queryKey: ["admin", "system-configs"],
queryFn: () => services.adminSystemConfig.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>
)
}
@@ -0,0 +1,8 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import {AdminSettingsPageClient} from "./page-client"
export default function AdminSettingsPage() {
return <AdminSettingsPageClient />
}
@@ -0,0 +1,247 @@
"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 {ShieldCheck} from "lucide-react"
import {formatDateTime} from "@/lib/utils"
import type {SystemConfig} from "@/lib/services/admin"
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>
<SystemConfigs />
</AdminProvider>
)
}
@@ -0,0 +1,374 @@
"use client"
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
import services from "@/lib/services"
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {Spinner} from "@/components/ui/spinner"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: "等待中",
running: "执行中",
succeeded: "成功",
failed: "失败",
}
const TRIGGER_LABELS: Record<string, string> = {
system: "系统",
manual: "手动",
retry: "重试",
schedule: "定时",
}
const PAGE_SIZE = 10
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return format(date, "yyyy-MM-dd HH:mm:ss")
}
function formatDuration(duration: number) {
if (!duration) return "-"
if (duration < 1000) return `${ duration }ms`
return `${ (duration / 1000).toFixed(2) }s`
}
function statusVariant(status: TaskExecutionStatus) {
if (status === "failed") return "destructive"
if (status === "succeeded") return "secondary"
return "outline"
}
export function TaskExecutionsManager() {
const queryClient = useQueryClient()
const [executionsPage, setExecutionsPage] = useState(1)
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.adminTask.getTaskTypes(),
})
const executionsQuery = useQuery({
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
queryFn: () => services.adminTask.listTaskExecutions({
page: executionsPage,
page_size: PAGE_SIZE,
status: executionStatus === "all" ? undefined : executionStatus,
task_type: executionTaskType === "all" ? undefined : executionTaskType,
}),
})
const detailQuery = useQuery({
queryKey: ["admin", "task-execution", selectedExecutionId],
queryFn: () => services.adminTask.getTaskExecution(selectedExecutionId!),
enabled: detailOpen && !!selectedExecutionId,
})
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success("任务已重新下发", {
description: `新任务 ID:${ taskID }`,
})
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
},
onError: (err: Error) => {
toast.error("任务重试失败", {
description: err.message || "未知错误",
})
},
})
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
const executions = executionsQuery.data?.items ?? []
const executionsTotal = executionsQuery.data?.total ?? 0
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
const executionsError = executionsQuery.isError ? executionsQuery.error : null
const selectedExecution = detailQuery.data ?? executionPreview
const detailLoading = detailQuery.isFetching
const totalPages = useMemo(
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
[executionsTotal],
)
const openExecutionDetail = (execution: TaskExecution) => {
setExecutionPreview(execution)
setSelectedExecutionId(execution.id)
setDetailOpen(true)
}
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
setExecutionStatus(value)
setExecutionsPage(1)
}
const handleTaskTypeChange = (value: string) => {
setExecutionTaskType(value)
setExecutionsPage(1)
}
return (
<div className="space-y-6">
<br/>
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
<SelectTrigger size="sm" className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">等待中</SelectItem>
<SelectItem value="running">执行中</SelectItem>
<SelectItem value="succeeded">成功</SelectItem>
<SelectItem value="failed">失败</SelectItem>
</SelectContent>
</Select>
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue placeholder="任务类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部任务</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
</div>
</div>
{executionsError ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
</div>
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[900px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px]">任务</TableHead>
<TableHead className="w-[100px]">状态</TableHead>
<TableHead className="w-[110px]">触发</TableHead>
<TableHead className="w-[120px]">重试</TableHead>
<TableHead className="w-[120px]">耗时</TableHead>
<TableHead className="min-w-[220px]">结果/错误</TableHead>
<TableHead className="w-[170px]">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className="cursor-pointer"
onClick={() => openExecutionDetail(execution)}
>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{execution.retry_count}/{execution.max_retry}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDuration(execution.duration)}
</TableCell>
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
{execution.error_message || execution.result || "-"}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className="size-4" />
上一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="w-full p-0 sm:max-w-[640px]">
<SheetHeader className="border-b">
<SheetTitle>任务执行详情</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
) : selectedExecution ? (
<div className="space-y-5 py-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">状态</div>
<div className="mt-2">
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
</Badge>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">触发来源</div>
<div className="mt-2 text-sm font-medium">
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">重试次数</div>
<div className="mt-2 font-mono text-sm">
{selectedExecution.retry_count}/{selectedExecution.max_retry}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">耗时</div>
<div className="mt-2 font-mono text-sm">
{formatDuration(selectedExecution.duration)}
</div>
</div>
</div>
<div className="grid gap-2">
<Label>任务标识</Label>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
{selectedExecution.task_id}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label>创建时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
</div>
<div className="grid gap-2">
<Label>开始时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
</div>
<div className="grid gap-2">
<Label>更新时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
</div>
</div>
<div className="grid gap-2">
<Label>执行结果</Label>
<div className="min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all">
{selectedExecution.result || "-"}
</div>
</div>
{selectedExecution.error_message && (
<div className="grid gap-2">
<Label>错误信息</Label>
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all">
{selectedExecution.error_message}
</div>
</div>
)}
<div className="grid gap-2">
<Label>Payload</Label>
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
{selectedExecution.payload || "{}"}
</pre>
</div>
<div className="grid gap-2">
<Label>执行日志</Label>
<pre className="min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap">
{selectedExecution.log || "暂无日志"}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
)}
</div>
<SheetFooter className="border-t">
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新详情
</Button>
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
重试任务
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
@@ -0,0 +1,444 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
import type {DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {cn} from "@/lib/utils"
import {format} from "date-fns"
import {zhCN} from "date-fns/locale"
import {Calendar} from "@/components/ui/calendar"
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
'order_sync': {
icon: Layers,
color: "text-blue-600 dark:text-blue-400",
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
},
'user_gamification': {
icon: Play,
color: "text-amber-600 dark:text-amber-400",
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
},
'dispute_auto_refund': {
icon: Clock,
color: "text-rose-600 dark:text-rose-400",
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
}
}
const DEFAULT_TASK_CONFIG = {
icon: Layers,
color: "text-zinc-600 dark:text-zinc-400",
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
}
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
const timeString = date ? format(date, "HH:mm:ss") : ""
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
setDate(undefined)
return
}
const updated = new Date(newDate)
if (date) {
updated.setHours(date.getHours())
updated.setMinutes(date.getMinutes())
updated.setSeconds(date.getSeconds())
} else {
updated.setHours(0, 0, 0, 0)
}
setDate(updated)
}
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
if (!newTime) return
if (date) {
const [hours, minutes, seconds] = newTime.split(':').map(Number)
const updated = new Date(date)
updated.setHours(hours || 0)
updated.setMinutes(minutes || 0)
updated.setSeconds(seconds || 0)
setDate(updated)
} else {
const today = new Date()
const [hours, minutes, seconds] = newTime.split(':').map(Number)
today.setHours(hours || 0)
today.setMinutes(minutes || 0)
today.setSeconds(seconds || 0)
setDate(today)
}
}
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<Input
type="time"
step="1"
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
/>
</div>
</div>
)
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
const [userId, setUserId] = useState("")
const [paramValues, setParamValues] = useState<Record<string, string>>({})
const fetchTaskTypes = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await services.adminTask.getTaskTypes()
setTaskTypes(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
if (targetTask?.params) {
const initialValues: Record<string, string> = {}
targetTask.params.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
} else {
setParamValues({})
}
}
}, [selectedTaskType, taskTypes])
const handleDispatch = async () => {
if (!selectedTaskType) return
try {
setDispatching(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const params: DispatchTaskRequest = {
task_type: selectedTaskType
}
if (targetTask?.supports_time) {
if (startTime) params.start_time = startTime.toISOString()
if (endTime) params.end_time = endTime.toISOString()
}
if (targetTask?.type === 'user_gamification') {
if (userId) params.user_id = userId
}
// Handle dynamic parameters — type coercion (number vs string) is
// centralised in buildTaskPayload; do not inline it here.
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
if (error) {
toast.error(error)
setDispatching(false)
return
}
params.payload = payload ?? undefined
}
const taskID = await services.adminTask.dispatchTask(params)
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
})
setDialogOpen(false)
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
setParamValues({})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setDispatching(false)
}
}
const openDispatchDialog = (type: string) => {
setSelectedTaskType(type)
setDialogOpen(true)
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return (
<div className="space-y-6">
<br/>
<div className="space-y-6">
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
return (
<div
key={`${ task.type }-${ index }`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
类型:{task.type}
</Badge>
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
重试:{task.max_retry}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<Button
className="w-full h-7 text-xs"
variant="secondary"
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
立即执行
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
</div>
</>
)
})()}
</div>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (targetTask?.supports_time) {
return (
<>
<div className="grid gap-2">
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
</div>
</>
)
}
return null
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return null
}
return (
<div className="space-y-4">
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1">
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[80px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
</span>
</div>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
)}
</div>
))}
</div>
)
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
const hasTime = targetTask?.supports_time
const hasParams = targetTask?.params && targetTask.params.length > 0
if (!hasTime && !hasParams) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4" />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
</div>
)
}
return null
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,473 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
import type {CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
export function TaskSchedulesManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [schedules, setSchedules] = useState<Schedule[]>([])
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
// Modal States
const [dialogOpen, setDialogOpen] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
// Delete States
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// Form Fields
const [name, setName] = useState("")
const [selectedTaskType, setSelectedTaskType] = useState("")
const [cron, setCron] = useState("")
const [isActive, setIsActive] = useState(true)
const [paramValues, setParamValues] = useState<Record<string, string>>({})
// Fetch Schedules & Task Types
const fetchData = useCallback(async () => {
try {
setLoading(true)
setError(null)
const [schedulesData, taskTypesData] = await Promise.all([
services.adminTask.listSchedules(),
services.adminTask.getTaskTypes()
])
setSchedules(schedulesData || [])
setTaskTypes(taskTypesData || [])
} catch (err) {
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Track task type selection to initialize parameters
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
// Parse existing payload when editing the same task type
try {
const parsed = JSON.parse(editingSchedule.payload || "{}")
const values: Record<string, string> = {}
targetTask?.params?.forEach(p => {
values[p.name] = String(parsed[p.name] ?? "")
})
setParamValues(values)
} catch {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
setParamValues({})
}
}, [selectedTaskType, taskTypes, editingSchedule])
const handleToggleActive = async (schedule: Schedule) => {
try {
const updated = await services.adminTask.updateSchedule(schedule.id, {
name: schedule.name,
task_type: schedule.task_type,
cron: schedule.cron,
payload: schedule.payload,
is_active: !schedule.is_active
})
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
} catch (err) {
toast.error("修改任务状态失败", {
description: err instanceof Error ? err.message : "未知错误"
})
}
}
const openCreateDialog = () => {
setEditingSchedule(null)
setName("")
setSelectedTaskType(taskTypes[0]?.type || "")
setCron("0 */2 * * *")
setIsActive(true)
setParamValues({})
setDialogOpen(true)
}
const openEditDialog = (schedule: Schedule) => {
setEditingSchedule(schedule)
setName(schedule.name)
setSelectedTaskType(schedule.task_type)
setCron(schedule.cron)
setIsActive(schedule.is_active)
setDialogOpen(true)
}
const handleSubmit = async () => {
if (!name.trim()) {
toast.error("任务名称不能为空")
return
}
if (!cron.trim()) {
toast.error("Cron 表达式不能为空")
return
}
try {
setSubmitLoading(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
let payloadStr = '{}'
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
if (error) {
toast.error(error)
setSubmitLoading(false)
return
}
payloadStr = payload ?? '{}'
}
if (editingSchedule) {
const req: UpdateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const updated = await services.adminTask.updateSchedule(editingSchedule.id, req)
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
toast.success("定时任务更新成功")
} else {
const req: CreateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const created = await services.adminTask.createSchedule(req)
setSchedules(prev => [created, ...prev])
toast.success("定时任务创建成功")
}
setDialogOpen(false)
} catch (err) {
toast.error("提交失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setSubmitLoading(false)
}
}
const openDeleteDialog = (id: string) => {
setDeletingId(id)
setDeleteOpen(true)
}
const handleDelete = async () => {
if (!deletingId) return
try {
setDeleteLoading(true)
await services.adminTask.deleteSchedule(deletingId)
setSchedules(prev => prev.filter(s => s.id !== deletingId))
toast.success("定时任务删除成功")
setDeleteOpen(false)
} catch (err) {
toast.error("删除失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setDeleteLoading(false)
}
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
const getTaskName = (type: string) => {
const meta = taskTypes.find(t => t.type === type)
return meta ? meta.name : type
}
return (
<div className="space-y-6">
<br/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
<Plus className="size-4 mr-1" />
新增定时任务
</Button>
</div>
</div>
{error ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
</div>
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
) : schedules.length === 0 ? (
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[800px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px]">任务名称</TableHead>
<TableHead className="w-[160px]">关联异步任务</TableHead>
<TableHead className="w-[120px]">Cron 表达式</TableHead>
<TableHead className="min-w-[200px]">执行参数 (Payload)</TableHead>
<TableHead className="w-[100px] text-center">启用状态</TableHead>
<TableHead className="w-[120px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">{schedule.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
{schedule.cron}
</TableCell>
<TableCell className="max-w-[300px] truncate">
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
{schedule.payload || "{}"}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Switch
checked={schedule.is_active}
onCheckedChange={() => handleToggleActive(schedule)}
/>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => openEditDialog(schedule)}
>
<Edit2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/90"
onClick={() => openDeleteDialog(schedule.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="sched-name">任务名称</Label>
<Input
id="sched-name"
placeholder="例如:每日数据清理"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-type">关联异步任务</Label>
<select
id="sched-type"
value={selectedTaskType}
onChange={(e) => setSelectedTaskType(e.target.value)}
disabled={!!editingSchedule}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{taskTypes.map(t => (
<option key={t.type} value={t.type}>
{t.name} ({t.type})
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-cron">Cron 表达式</Label>
<Input
id="sched-cron"
placeholder="e.g. 0 */2 * * * 或 @daily"
value={cron}
onChange={(e) => setCron(e.target.value)}
className="text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="sched-active">启用状态</Label>
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
</div>
<Switch
id="sched-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4 shrink-0" />
<span>所选任务类型不需要运行参数参数。</span>
</div>
)
}
return (
<div className="space-y-4 pt-2 border-t">
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[70px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
</span>
</div>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
)}
</div>
))}
</div>
)
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
{submitLoading && <Spinner className="size-3 mr-1" />}
{editingSchedule ? '保存修改' : '确认创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
取消
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
{deleteLoading && <Spinner className="size-3 mr-1" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,65 @@
"use client"
import {Suspense} from "react"
import {useRouter, useSearchParams} from "next/navigation"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {TaskManager} from "./components/task-manager"
import {TaskSchedulesManager} from "./components/task-schedules"
import {TaskExecutionsManager} from "./components/task-executions"
import {Spinner} from "@/components/ui/spinner"
import {Layers} from "lucide-react"
function TasksPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const activeTab = searchParams.get("tab") || "tasks"
const handleTabChange = (value: string) => {
router.push(`/admin/tasks?tab=${value}`)
}
return (
<div className="py-6 space-y-6">
<div className="flex items-center gap-2">
<Layers className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">任务管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<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 />
</TabsContent>
<TabsContent value="schedules" className="space-y-4 outline-none">
<TaskSchedulesManager />
</TabsContent>
<TabsContent value="executions" className="space-y-4 outline-none">
<TaskExecutionsManager />
</TabsContent>
</Tabs>
</div>
)
}
export default function TasksPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<Spinner className="h-8 w-8" />
</div>
}>
<TasksPageContent />
</Suspense>
)
}
@@ -0,0 +1,182 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import type {CreateUserRequest} from "@/lib/services/admin"
const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
email: "",
is_active: true,
is_admin: false,
}
export function CreateUserModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const { createUser } = useAdminUsers()
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (isOpen) {
setForm(emptyForm)
setErrors({})
} else {
setSaving(false)
}
}, [isOpen])
const validate = () => {
const newErrors: Record<string, string> = {}
if (!form.username.trim()) {
newErrors.username = "用户名不能为空"
} else if (form.username.trim().length < 3) {
newErrors.username = "用户名长度不能少于 3 位"
}
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
}
if (!form.password) {
newErrors.password = "密码不能为空"
} else if (form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setSaving(true)
try {
await createUser({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
email: form.email.trim(),
})
onClose()
} catch {
// Errors are already handled by context toast notifications
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="请输入用户名 (至少 3 位)"
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<Input
id="nickname"
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="请输入登录密码 (至少 8 位)"
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">启用账户</div>
<div className="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">管理员权限</div>
<div className="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
</div>
<Switch
checked={form.is_admin}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "创建中..." : "创建"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -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/admin"
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>
)
}
@@ -0,0 +1,264 @@
"use client"
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 type {AdminUser} from "@/lib/services/admin"
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/create-user-modal"
import {UserFilterBar} from "./components/user-filter-bar"
import {UserDetailSheet} from "./components/user-detail-sheet"
export default function UsersPage() {
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">
<TooltipProvider delayDuration={0}>
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<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">
<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>
<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>
{!user.is_admin && (
<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>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TooltipProvider>
</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>
)
}
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {RequireAuth} from "@/components/auth/require-auth"
import {UserFileManager} from "@/components/common/user/file-manager"
import {FolderOpen} from "lucide-react"
export default function UserFilesPage() {
return (
<RequireAuth>
<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>
</RequireAuth>
)
}
@@ -0,0 +1,5 @@
import {HomeMain} from "@/components/common/home/home-main"
export default function HomePage() {
return <HomeMain />
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import {motion} from "motion/react"
import {usePathname} from "next/navigation"
import {useState} from "react"
import {AppSidebar} from "@/components/layout/sidebar"
import {SiteHeader} from "@/components/layout/header"
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"
import {useAuthRedirect} from "@/hooks/use-auth-redirect"
export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
const pathname = usePathname()
const [isFullWidth, setIsFullWidth] = useState(false)
useAuthRedirect()
return (
<SidebarProvider
className="h-screen"
style={
{
"--header-height": "60px",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex flex-col min-w-0 h-screen">
<SiteHeader isFullWidth={isFullWidth} onToggleFullWidth={setIsFullWidth} />
<div className="flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar">
<div className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"}`}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
ease: "easeOut",
}}
className="w-full"
>
{children}
</motion.div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}
@@ -0,0 +1,7 @@
"use client"
import {AccessTokenMain} from "@/components/common/settings/access-token"
export default function AccessTokenPage() {
return <AccessTokenMain />
}
@@ -0,0 +1,5 @@
import {AppearanceMain} from "@/components/common/settings/appearance"
export default function AppearancePage() {
return <AppearanceMain />
}
@@ -0,0 +1,11 @@
"use client"
import {RequireAuth} from "@/components/auth/require-auth"
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return <RequireAuth>{children}</RequireAuth>
}
@@ -0,0 +1,5 @@
import {NotificationsMain} from "@/components/common/settings/notifications"
export default function NotificationsPage() {
return <NotificationsMain />
}
@@ -0,0 +1,78 @@
"use client"
import Link from "next/link"
import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card"
import {Bell, Key, Palette, UserRound} from "lucide-react"
/* 设置项 */
const settingsItems = [
{
title: "个人资料",
description: "查看您的账户信息",
icon: UserRound,
href: "/settings/profile",
category: "个人设置",
},
{
title: "访问令牌 (AccessToken)",
description: "管理用于 API 访问的个人 Token",
icon: Key,
href: "/settings/access-token",
category: "个人设置",
},
{
title: "通知设置",
description: "设置您的通知偏好",
icon: Bell,
href: "/settings/notifications",
category: "账户设置",
},
{
title: "外观设置",
description: "自定义界面主题 and 显示",
icon: Palette,
href: "/settings/appearance",
category: "个人设置",
},
]
export default function SettingsPage() {
const groupedSettings = settingsItems.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, typeof settingsItems>)
return (
<div className="space-y-6 py-6">
<div className="font-semibold text-lg">设置</div>
{Object.entries(groupedSettings).map(([category, items]) => (
<div key={category} className="space-y-4">
<div className="font-medium text-sm text-muted-foreground">{category}</div>
<div className="grid grid-cols-2 gap-4">
{items.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="py-2 border border-dashed shadow-none hover:bg-muted/50 transition-colors cursor-pointer h-full">
<CardContent>
<div className="flex items-center gap-4">
<item.icon className="size-5 text-primary" />
<div>
<CardTitle className="mb-1 text-sm">{item.title}</CardTitle>
<CardDescription className="text-xs">
{item.description}
</CardDescription>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,5 @@
import {ProfileMain} from "@/components/common/settings/profile"
export default function ProfilePage() {
return <ProfileMain />
}
@@ -0,0 +1,5 @@
import {redirect} from "next/navigation"
export default function SecurityPage() {
redirect("/admin/settings")
}
+196
View File
@@ -0,0 +1,196 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(58.5% 51% 277.117);
--primary-foreground: oklch(98.5% 0% 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@theme inline {
/*
* Aurora 动画配置
*/
--animate-aurora: aurora 120s linear infinite;
@keyframes aurora {
from {
background-position:
50% 50%,
50% 50%;
}
to {
background-position:
350% 50%,
350% 50%;
}
}
/*
* 红包动画配置
*/
--animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out;
--animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite;
--animate-gold-particle: gold-particle 1s ease-out forwards;
@keyframes redenvelope-shake {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes redenvelope-glow {
0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); }
50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); }
}
@keyframes gold-particle {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-100px) scale(0); opacity: 0; }
}
/*
* 铃铛响铃动画配置
* 使用微妙的摆动效果,优雅且不过度
*/
--animate-bell-ring: bell-ring 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97);
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
70% { transform: rotate(2deg); }
80% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
}
+33
View File
@@ -0,0 +1,33 @@
import {ImageResponse} from 'next/og'
import {WavesIcon} from 'lucide-react'
export const dynamic = 'force-static'
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
background: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '20%',
}}
>
<WavesIcon color="white" size={20} />
</div>
),
{
...size,
}
)
}
+56
View File
@@ -0,0 +1,56 @@
import type {Metadata} from "next";
import {Toaster} from "@/components/ui/sonner";
import {ThemeProvider} from "@/components/layout/theme-provider";
import {CustomThemeProvider} from "@/lib/theme";
import {BellRingProvider} from "@/contexts/bell-ring-context";
import {NotificationSettingsProvider} from "@/contexts/notification-settings-context";
import {UserProvider} from "@/contexts/user-context";
import {AppQueryProvider} from "@/components/providers/query-provider";
import {SiteTitleUpdater} from "@/components/providers/title-updater";
import {RobotsMeta} from "@/components/layout/robots-meta";
import "./globals.css";
export const metadata: Metadata = {
title: "Wavelet Platform",
description: "通用二次开发标准范式平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
className="hide-scrollbar font-sans"
suppressHydrationWarning
>
<body
className="hide-scrollbar font-sans antialiased"
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<CustomThemeProvider>
<AppQueryProvider>
<SiteTitleUpdater />
<RobotsMeta />
<UserProvider>
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
</UserProvider>
</AppQueryProvider>
</CustomThemeProvider>
</ThemeProvider>
</body>
</html>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client"
import {useRouter} from "next/navigation";
import Link from "next/link";
import {motion} from "motion/react";
import {ArrowLeft, Home} from "lucide-react";
import {Button} from "@/components/ui/button";
/**
* 404 Not Found Page
* 当用户访问不存在的页面时显示
*/
export default function NotFound() {
const router = useRouter();
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20">
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-primary/20 rounded-full blur-[120px] opacity-20 animate-pulse" />
</div>
<div className="container px-6 flex flex-col items-center text-center z-10">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="relative mb-6"
>
<h1 className="text-[12rem] md:text-[16rem] font-bold leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-foreground/10 to-foreground/5 select-none">
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase">Page Not Found</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="space-y-8"
>
<p className="text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed">
抱歉,您访问的页面似已迷失在数字星云中。
<br />
请检查路径或返回安全地带。
</p>
<div className="flex justify-center gap-4">
<Button
variant="secondary"
size="sm"
onClick={() => router.back()}
className="rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300"
>
<ArrowLeft className="size-3 opacity-70" />
上一页
</Button>
<Link href="/">
<Button
variant="default"
size="sm"
className="rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300"
>
<Home className="size-3" />
首页
</Button>
</Link>
</div>
</motion.div>
</div>
<div className="absolute bottom-8 text-xs text-muted-foreground/30 font-mono">
ERR_HTTP_NOT_FOUND
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { HeroSection } from "@/components/home/hero-section";
import { DeveloperSection } from "@/components/home/developer-section";
import { FooterSection } from "@/components/home/footer-section";
export default function HomePage() {
return (
<div className="relative w-full min-h-screen bg-transparent text-foreground font-sans overflow-x-hidden selection:bg-primary selection:text-primary-foreground z-10">
<HeroSection />
<DeveloperSection />
<FooterSection />
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import {cva, type VariantProps} from 'class-variance-authority';
import {
Button as ButtonPrimitive,
type ButtonProps as ButtonPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/button';
import {cn} from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
'icon-lg': 'size-10 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<ButtonPrimitive
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import {type VariantProps} from 'class-variance-authority';
import {buttonVariants} from '@/components/animate-ui/components/buttons/button';
import {
RippleButton as RippleButtonPrimitive,
type RippleButtonProps as RippleButtonPrimitiveProps,
RippleButtonRipples as RippleButtonRipplesPrimitive,
type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/ripple';
import {cn} from '@/lib/utils';
const rippleButtonVariants = {
default: '[--ripple-button-ripple-color:var(--primary-foreground)]',
accent: '[--ripple-button-ripple-color:var(--accent-foreground)]',
destructive: '[--ripple-button-ripple-color:var(--destructive-foreground)]',
outline: '[--ripple-button-ripple-color:var(--foreground)]',
secondary: '[--ripple-button-ripple-color:var(--secondary-foreground)]',
ghost: '[--ripple-button-ripple-color:var(--foreground)]',
link: '[--ripple-button-ripple-color:var(--primary-foreground)]',
};
type RippleButtonProps = RippleButtonPrimitiveProps &
VariantProps<typeof buttonVariants>;
function RippleButton({
className,
variant,
size,
...props
}: RippleButtonProps) {
return (
<RippleButtonPrimitive
className={cn(
buttonVariants({ variant, size, className }),
rippleButtonVariants[variant as keyof typeof rippleButtonVariants],
)}
{...props}
/>
);
}
type RippleButtonRipplesProps = RippleButtonRipplesPrimitiveProps;
function RippleButtonRipples(props: RippleButtonRipplesProps) {
return <RippleButtonRipplesPrimitive {...props} />;
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,70 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type CheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="initial"
animate={controls}
{...props}
>
<motion.path
d="m4 12 5 5L20 6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function Check(props: CheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Check,
Check as CheckIcon,
type CheckProps,
type CheckProps as CheckIconProps,
};
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type ChevronDownProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
y: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
y: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
y: 0,
},
animate: {
y: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronDownProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m6 9 6 6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronDown(props: ChevronDownProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronDown,
ChevronDown as ChevronDownIcon,
type ChevronDownProps,
type ChevronDownProps as ChevronDownIconProps,
};
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type ChevronLeftProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: -4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, -4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronLeftProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m15 18-6-6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronLeft(props: ChevronLeftProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronLeft,
ChevronLeft as ChevronLeftIcon,
type ChevronLeftProps,
type ChevronLeftProps as ChevronLeftIconProps,
};
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type ChevronRightProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronRightProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m9 18 6-6-6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronRight(props: ChevronRightProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronRight,
ChevronRight as ChevronRightIcon,
type ChevronRightProps,
type ChevronRightProps as ChevronRightIconProps,
};
@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type CircleCheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
circle: {},
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CircleCheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.circle
cx="12"
cy="12"
r="10"
variants={variants.circle}
initial="initial"
animate={controls}
/>
<motion.path
d="m9 12 2 2 4-4"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function CircleCheck(props: CircleCheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
CircleCheck,
CircleCheck as CircleCheckIcon,
type CircleCheckProps,
type CircleCheckProps as CircleCheckIconProps,
};
@@ -0,0 +1,655 @@
'use client';
import * as React from 'react';
import {
type HTMLMotionProps,
type LegacyAnimationControls,
motion,
type SVGMotionProps,
useAnimation,
type UseInViewOptions,
type Variants,
} from 'motion/react';
import {cn} from '@/lib/utils';
import {useIsInView} from '@/hooks/use-is-in-view';
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
const staticAnimations = {
path: {
initial: { pathLength: 1 },
animate: {
pathLength: [0.05, 1],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
} as Variants,
'path-loop': {
initial: { pathLength: 1 },
animate: {
pathLength: [1, 0.05, 1],
transition: {
duration: 1.6,
ease: 'easeInOut',
},
},
} as Variants,
} as const;
type StaticAnimations = keyof typeof staticAnimations;
type TriggerProp<T = string> = boolean | StaticAnimations | T;
type Trigger = TriggerProp<string>;
type AnimateIconContextValue = {
controls: LegacyAnimationControls | undefined;
animation: StaticAnimations | string;
loop: boolean;
loopDelay: number;
active: boolean;
animate?: Trigger;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type DefaultIconProps<T = string> = {
animate?: TriggerProp<T>;
animateOnHover?: TriggerProp<T>;
animateOnTap?: TriggerProp<T>;
animateOnView?: TriggerProp<T>;
animateOnViewMargin?: UseInViewOptions['margin'];
animateOnViewOnce?: boolean;
animation?: T | StaticAnimations;
loop?: boolean;
loopDelay?: number;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type AnimateIconProps<T = string> = WithAsChild<
HTMLMotionProps<'span'> &
DefaultIconProps<T> & {
children: React.ReactNode;
asChild?: boolean;
}
>;
type IconProps<T> = DefaultIconProps<T> &
Omit<SVGMotionProps<SVGSVGElement>, 'animate'> & {
size?: number;
};
type IconWrapperProps<T> = IconProps<T> & {
icon: React.ComponentType<IconProps<T>>;
};
const AnimateIconContext = React.createContext<AnimateIconContextValue | null>(
null,
);
function useAnimateIconContext() {
const context = React.useContext(AnimateIconContext);
if (!context)
return {
controls: undefined,
animation: 'default',
loop: undefined,
loopDelay: undefined,
active: undefined,
animate: undefined,
initialOnAnimateEnd: undefined,
completeOnStop: undefined,
persistOnAnimateEnd: undefined,
delay: undefined,
};
return context;
}
function composeEventHandlers<E extends React.SyntheticEvent<unknown>>(
theirs?: (event: E) => void,
ours?: (event: E) => void,
) {
return (event: E) => {
theirs?.(event);
ours?.(event);
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyProps = Record<string, any>;
function AnimateIcon({
asChild = false,
animate = false,
animateOnHover = false,
animateOnTap = false,
animateOnView = false,
animateOnViewMargin = '0px',
animateOnViewOnce = true,
animation = 'default',
loop = false,
loopDelay = 0,
initialOnAnimateEnd = false,
completeOnStop = false,
persistOnAnimateEnd = false,
delay = 0,
children,
...props
}: AnimateIconProps) {
const controls = useAnimation();
const [localAnimate, setLocalAnimate] = React.useState<boolean>(() => {
if (animate === undefined || animate === false) return false;
return delay <= 0;
});
const [currentAnimation, setCurrentAnimation] = React.useState<
string | StaticAnimations
>(typeof animate === 'string' ? animate : animation);
const [status, setStatus] = React.useState<'initial' | 'animate'>('initial');
const delayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const loopDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const isAnimateInProgressRef = React.useRef<boolean>(false);
const animateEndPromiseRef = React.useRef<Promise<void> | null>(null);
const resolveAnimateEndRef = React.useRef<(() => void) | null>(null);
const activeRef = React.useRef<boolean>(localAnimate);
const runGenRef = React.useRef(0);
const cancelledRef = React.useRef(false);
const bumpGeneration = React.useCallback(() => {
runGenRef.current++;
}, []);
const startAnimation = React.useCallback(
(trigger: TriggerProp) => {
const next = typeof trigger === 'string' ? trigger : animation;
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
setCurrentAnimation(next);
if (delay > 0) {
setLocalAnimate(false);
delayRef.current = setTimeout(() => {
setLocalAnimate(true);
}, delay);
} else {
setLocalAnimate(true);
}
},
[animation, delay, bumpGeneration],
);
const stopAnimation = React.useCallback(() => {
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
setLocalAnimate(false);
}, [bumpGeneration]);
React.useEffect(() => {
activeRef.current = localAnimate;
}, [localAnimate]);
React.useEffect(() => {
if (animate === undefined) return;
setCurrentAnimation(typeof animate === 'string' ? animate : animation);
if (animate) startAnimation(animate as TriggerProp);
else stopAnimation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animate]);
React.useEffect(() => {
return () => {
if (delayRef.current) clearTimeout(delayRef.current);
if (loopDelayRef.current) clearTimeout(loopDelayRef.current);
};
}, []);
const viewOuterRef = React.useRef<HTMLElement>(null);
const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, {
inView: !!animateOnView,
inViewOnce: animateOnViewOnce,
inViewMargin: animateOnViewMargin,
});
const startAnim = React.useCallback(
async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => {
try {
await controls[method](anim);
setStatus(anim);
} catch {
return;
}
},
[controls],
);
React.useEffect(() => {
if (!animateOnView) return;
if (isInView) startAnimation(animateOnView);
else stopAnimation();
}, [isInView, animateOnView, startAnimation, stopAnimation]);
React.useEffect(() => {
const gen = ++runGenRef.current;
cancelledRef.current = false;
async function run() {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!localAnimate) {
if (
completeOnStop &&
isAnimateInProgressRef.current &&
animateEndPromiseRef.current
) {
try {
await animateEndPromiseRef.current;
} catch {
// noop
}
}
if (!persistOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial');
}
return;
}
if (loop) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
isAnimateInProgressRef.current = true;
animateEndPromiseRef.current = new Promise<void>((resolve) => {
resolveAnimateEndRef.current = resolve;
});
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
await startAnim('animate');
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
if (initialOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
if (loop) {
if (loopDelay > 0) {
await new Promise<void>((resolve) => {
loopDelayRef.current = setTimeout(() => {
loopDelayRef.current = null;
resolve();
}, loopDelay);
});
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
} else {
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
}
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await run();
}
}
void run();
return () => {
cancelledRef.current = true;
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localAnimate, controls]);
const childProps = (
React.isValidElement(children) ? (children as React.ReactElement).props : {}
) as AnyProps;
const handleMouseEnter = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseEnter,
() => {
if (animateOnHover) startAnimation(animateOnHover);
},
);
const handleMouseLeave = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseLeave,
() => {
if (animateOnHover || animateOnTap) stopAnimation();
},
);
const handlePointerDown = composeEventHandlers<
React.PointerEvent<HTMLElement>
>(childProps.onPointerDown, () => {
if (animateOnTap) startAnimation(animateOnTap);
});
const handlePointerUp = composeEventHandlers<React.PointerEvent<HTMLElement>>(
childProps.onPointerUp,
() => {
if (animateOnTap) stopAnimation();
},
);
const content = asChild ? (
<Slot
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</Slot>
) : (
<motion.span
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</motion.span>
);
return (
<AnimateIconContext.Provider
value={{
controls,
animation: currentAnimation,
loop,
loopDelay,
active: localAnimate,
animate,
initialOnAnimateEnd,
completeOnStop,
delay,
}}
>
{content}
</AnimateIconContext.Provider>
);
}
const pathClassName =
"[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]";
function IconWrapper<T extends string>({
size = 28,
animation: animationProp,
animate,
animateOnHover,
animateOnTap,
animateOnView,
animateOnViewMargin,
animateOnViewOnce,
icon: IconComponent,
loop,
loopDelay,
persistOnAnimateEnd,
initialOnAnimateEnd,
delay,
completeOnStop,
className,
...props
}: IconWrapperProps<T>) {
const context = React.useContext(AnimateIconContext);
if (context) {
const {
controls,
animation: parentAnimation,
loop: parentLoop,
loopDelay: parentLoopDelay,
active: parentActive,
animate: parentAnimate,
persistOnAnimateEnd: parentPersistOnAnimateEnd,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
} = context;
const hasOverrides =
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
loop !== undefined ||
loopDelay !== undefined ||
initialOnAnimateEnd !== undefined ||
persistOnAnimateEnd !== undefined ||
delay !== undefined ||
completeOnStop !== undefined;
if (hasOverrides) {
const inheritedAnimate: Trigger = parentActive
? (animationProp ?? parentAnimation ?? 'default')
: false;
const finalAnimate: Trigger = (animate ??
parentAnimate ??
inheritedAnimate) as Trigger;
return (
<AnimateIcon
animate={finalAnimate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp ?? parentAnimation}
loop={loop ?? parentLoop}
loopDelay={loopDelay ?? parentLoopDelay}
persistOnAnimateEnd={persistOnAnimateEnd ?? parentPersistOnAnimateEnd}
initialOnAnimateEnd={initialOnAnimateEnd ?? parentInitialOnAnimateEnd}
delay={delay ?? parentDelay}
completeOnStop={completeOnStop ?? parentCompleteOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
((animationProp ?? parentAnimation) === 'path' ||
(animationProp ?? parentAnimation) === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
const animationToUse = animationProp ?? parentAnimation;
const loopToUse = parentLoop;
const loopDelayToUse = parentLoopDelay;
return (
<AnimateIconContext.Provider
value={{
controls,
animation: animationToUse,
loop: loopToUse,
loopDelay: loopDelayToUse,
active: parentActive,
animate: parentAnimate,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
}}
>
<IconComponent
size={size}
className={cn(
className,
(animationToUse === 'path' || animationToUse === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIconContext.Provider>
);
}
if (
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
animationProp !== undefined
) {
return (
<AnimateIcon
animate={animate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp}
loop={loop}
loopDelay={loopDelay}
delay={delay}
completeOnStop={completeOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
return (
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
);
}
function getVariants<
V extends { default: T; [key: string]: T },
T extends Record<string, Variants>,
>(animations: V): T {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { animation: animationType } = useAnimateIconContext();
let result: T;
if (animationType in staticAnimations) {
const variant = staticAnimations[animationType as StaticAnimations];
result = {} as T;
for (const key in animations.default) {
if (
(animationType === 'path' || animationType === 'path-loop') &&
key.includes('group')
)
continue;
result[key] = variant as T[Extract<keyof T, string>];
}
} else {
result = (animations[animationType as keyof V] as T) ?? animations.default;
}
return result;
}
export {
pathClassName,
staticAnimations,
AnimateIcon,
IconWrapper,
useAnimateIconContext,
getVariants,
type IconProps,
type IconWrapperProps,
type AnimateIconProps,
type AnimateIconContextValue,
};
@@ -0,0 +1,87 @@
'use client';
import * as React from 'react';
import {motion, type Variants} from 'motion/react';
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
type SendProps = IconProps<keyof typeof animations>;
const animations = {
default: {
group: {
initial: {
scale: 1,
x: 0,
y: 0,
},
animate: {
scale: [1, 0.8, 1, 1, 1],
x: [0, '-10%', '100%', '-125%', 0],
y: [0, '10%', '-100%', '125%', 0],
transition: {
default: { ease: 'easeInOut', duration: 1.2 },
x: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
y: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
},
},
},
path1: {},
path2: {},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: SendProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.g variants={variants.group} initial="initial" animate={controls}>
<motion.path
d="M14.5,21.7c.1.3.4.4.7.3.1,0,.2-.2.3-.3L22,2.7c0-.3,0-.5-.3-.6-.1,0-.2,0-.3,0L2.3,8.5c-.3,0-.4.4-.3.6,0,.1.2.2.3.3l7.9,3.2c.5.2.9.6,1.1,1.1l3.2,7.9Z"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M21.9,2.1l-10.9,10.9"
variants={variants.path2}
initial="initial"
animate={controls}
/>
</motion.g>
</motion.svg>
);
}
function Send(props: SendProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Send,
Send as SendIcon,
type SendProps,
type SendProps as SendIconProps,
};
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, isMotionComponent, motion} from 'motion/react';
import {cn} from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, motion} from 'motion/react';
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
type ButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
export { Button, type ButtonProps };
@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, motion} from 'motion/react';
import {getStrictContext} from '@/lib/utils/get-strict-context';
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonContextType = {
ripples: Ripple[];
setRipples: (ripples: Ripple[]) => void;
};
const [RippleButtonProvider, useRippleButton] =
getStrictContext<RippleButtonContextType>('RippleButtonContext');
type RippleButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function RippleButton({
ref,
onClick,
hoverScale = 1,
tapScale = 0.95,
asChild = false,
style,
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
const Component = asChild ? Slot : motion.button;
return (
<RippleButtonProvider value={{ ripples, setRipples }}>
<Component
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
style={{
position: 'relative',
overflow: 'hidden',
...style,
}}
{...props}
/>
</RippleButtonProvider>
);
}
type RippleButtonRipplesProps = WithAsChild<
HTMLMotionProps<'span'> & {
color?: string;
scale?: number;
}
>;
function RippleButtonRipples({
color = 'var(--ripple-button-ripple-color)',
scale = 10,
transition = { duration: 0.6, ease: 'easeOut' },
asChild = false,
style,
...props
}: RippleButtonRipplesProps) {
const { ripples } = useRippleButton();
const Component = asChild ? Slot : motion.span;
return ripples.map((ripple) => (
<Component
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
style={{
position: 'absolute',
borderRadius: '50%',
pointerEvents: 'none',
width: '20px',
height: '20px',
backgroundColor: color,
top: ripple.y - 10,
left: ripple.x - 10,
...style,
}}
{...props}
/>
));
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import {Collapsible as CollapsiblePrimitive} from 'radix-ui';
import {AnimatePresence, type HTMLMotionProps, motion} from 'motion/react';
import {getStrictContext} from '@/lib/utils/get-strict-context';
import {useControlledState} from '@/hooks/use-controlled-state';
type CollapsibleContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [CollapsibleProvider, useCollapsible] =
getStrictContext<CollapsibleContextType>('CollapsibleContext');
type CollapsibleProps = React.ComponentProps<typeof CollapsiblePrimitive.Root>;
function Collapsible(props: CollapsibleProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
<CollapsiblePrimitive.Root
data-slot="collapsible"
{...props}
onOpenChange={setIsOpen}
/>
</CollapsibleProvider>
);
}
type CollapsibleTriggerProps = React.ComponentProps<
typeof CollapsiblePrimitive.Trigger
>;
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
type CollapsibleContentProps = Omit<
React.ComponentProps<typeof CollapsiblePrimitive.Content>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'> & {
keepRendered?: boolean;
};
function CollapsibleContent({
keepRendered = false,
transition = { duration: 0.35, ease: 'easeInOut' },
...props
}: CollapsibleContentProps) {
const { isOpen } = useCollapsible();
return (
<AnimatePresence>
{keepRendered ? (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={
isOpen
? { opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }
: { opacity: 0, height: 0, overflow: 'hidden', y: 20 }
}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
) : (
isOpen && (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={{ opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }}
exit={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
)
)}
</AnimatePresence>
);
}
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
useCollapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
type CollapsibleContextType,
};
@@ -0,0 +1,133 @@
'use client';
import * as React from 'react';
import {type SpringOptions, useMotionValue, useSpring} from 'motion/react';
import {useIsInView, type UseIsInViewOptions,} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
thousandSeparator?: string;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 200, damping: 30 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
thousandSeparator = '',
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
: paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = formatted.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
formatted = fracPart
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
: separatedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = out.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };
@@ -0,0 +1,67 @@
"use client"
import type {ReactNode} from "react"
import {motion} from "motion/react"
import {WavesIcon} from "lucide-react"
import {cn} from "@/lib/utils"
interface AuthShellProps {
children: ReactNode
wide?: boolean
}
export function AuthShell({children, wide = false}: AuthShellProps) {
return (
<main className="relative flex min-h-screen w-full items-center justify-center overflow-x-hidden bg-background px-4 py-6 sm:px-8 sm:py-8 [@media(max-height:700px)]:py-3">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-45 [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
<motion.section
initial={{opacity: 0, y: 18}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.45, ease: "easeOut"}}
className={cn(
"relative w-full border-x border-dashed border-border/80 bg-background/95 px-6 py-8 backdrop-blur-sm sm:px-10 sm:py-10 [@media(max-height:700px)]:py-5",
wide ? "max-w-2xl" : "max-w-xl",
)}
>
{children}
</motion.section>
</main>
)
}
interface AuthHeadingProps {
title: string
description: string
siteName?: string
}
export function AuthHeading({
title,
description,
siteName = "Wavelet",
}: AuthHeadingProps) {
return (
<header className="flex flex-col gap-6 [@media(max-height:700px)]:gap-3">
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-full bg-foreground text-background [@media(max-height:700px)]:size-8">
<WavesIcon aria-hidden="true" />
</span>
<span className="font-semibold tracking-tight [@media(min-height:761px)]:text-lg">{siteName}</span>
</div>
<div className="flex flex-col gap-1.5 [@media(max-height:700px)]:gap-1">
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl [@media(min-height:900px)]:sm:text-4xl">
{title}
</h1>
<p className="text-sm text-muted-foreground [@media(min-height:900px)]:text-base">{description}</p>
</div>
</header>
)
}
@@ -0,0 +1,110 @@
'use client'
import {useEffect, useRef, useState} from 'react'
import {CheckCircle2, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion} from 'lucide-react'
import {getCapToken} from '@/lib/cap-solver'
type CapStatus = 'idle' | 'solving' | 'solved' | 'error'
interface CapWidgetProps {
/** Called with the one-time token once the challenge is solved */
onToken: (token: string) => void
/** Called when the challenge fails or encounters an error */
onError?: (err: Error) => void
/** Whether to auto-start solving when the component mounts. Defaults to true. */
autoStart?: boolean
/** PoW scope sent to the backend */
scope?: string
}
/**
* Cap 人机验证小部件
*
* autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。
* autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。
*/
export function CapWidget({ onToken, onError, autoStart = true, scope = 'login' }: CapWidgetProps) {
const [status, setStatus] = useState<CapStatus>('idle')
const [errorMsg, setErrorMsg] = useState('')
const solving = useRef(false)
const solve = async () => {
if (solving.current) return
solving.current = true
setStatus('solving')
setErrorMsg('')
try {
const token = await getCapToken(scope)
setStatus('solved')
onToken(token)
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
setStatus('error')
setErrorMsg(error.message)
onError?.(error)
} finally {
solving.current = false
}
}
// Auto-start on mount (only when autoStart is enabled)
useEffect(() => {
if (autoStart) {
void solve()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
{/* idle + autoStart=false: 手动触发按钮 */}
{status === 'idle' && !autoStart && (
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={() => void solve()}
>
<ShieldQuestion className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground">点击开始人机验证</span>
</button>
)}
{/* idle + autoStart=true: 等待自动开始(极短暂状态) */}
{status === 'idle' && autoStart && (
<>
<ShieldAlert className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">等待人机验证…</span>
</>
)}
{status === 'solving' && (
<>
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
<span className="text-muted-foreground">正在完成人机验证…</span>
</>
)}
{status === 'solved' && (
<>
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
<span className="text-green-600 dark:text-green-400">人机验证通过</span>
<ShieldCheck className="ml-auto size-4 shrink-0 text-green-500" />
</>
)}
{status === 'error' && (
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={() => void solve()}
>
<ShieldAlert className="size-4 shrink-0 text-destructive" />
<span className="flex-1 text-destructive">
{errorMsg || '人机验证失败'}
</span>
<span className="text-xs text-muted-foreground underline">重试</span>
</button>
)}
</div>
)
}
@@ -0,0 +1,347 @@
"use client"
import {useEffect, useRef, useState} from "react"
import {useMutation, useQuery} from "@tanstack/react-query"
import {useSearchParams} from "next/navigation"
import {EyeIcon, EyeOffIcon} from "lucide-react"
import {toast} from "sonner"
import Link from "next/link"
import {useAuth} from "@/components/providers/auth-provider"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {Spinner} from "@/components/ui/spinner"
import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
import {CapWidget} from "@/components/auth/cap-widget"
import {AuthHeading} from "@/components/auth/auth-shell"
import {OTPForm} from "./otp-form"
import {AuthService} from "@/lib/services/auth"
import {ConfigService} from "@/lib/services/config"
import type {LoginRequest} from "@/lib/services/auth/types"
import {safeRedirectTarget} from "@/lib/utils"
function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
const callbackUrl = searchParams.get("callbackUrl")
if (callbackUrl && typeof window !== "undefined") {
sessionStorage.setItem("redirect_after_login", safeRedirectTarget(callbackUrl))
}
}
function configBool(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback
return value === "true"
}
export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: boolean) => void }) {
const searchParams = useSearchParams()
const { setUser } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [code, setCode] = useState("")
const [showLoginCodeInput, setShowLoginCodeInput] = useState(false)
const [loginCooldown, setLoginCooldown] = useState(0)
const [errorMessage, setErrorMessage] = useState("")
const [loginCodeTip, setLoginCodeTip] = useState<React.ReactNode>(null)
useEffect(() => {
onOTPStateChange?.(showLoginCodeInput)
}, [showLoginCodeInput, onOTPStateChange])
useEffect(() => {
if (loginCooldown > 0) {
const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000)
return () => clearTimeout(timer)
}
}, [loginCooldown])
// Cap token management — ref to hold latest token without triggering re-render
const capTokenRef = useRef<string | null>(null)
const [capReady, setCapReady] = useState(false)
const [capError, setCapError] = useState(false)
const [capResetKey, setCapResetKey] = useState(0)
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryFn: () => ConfigService.getPublicConfig(),
})
const authSourcesQuery = useQuery({
queryKey: ["auth-sources"],
queryFn: () => AuthService.getAuthSources(),
})
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const loginMutation = useMutation({
mutationFn: (req: LoginRequest) => {
const headers: Record<string, string> = {}
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
// Consume the token — next login attempt will need a new one
capTokenRef.current = null
setCapReady(false)
}
return AuthService.login(req, Object.keys(headers).length ? headers : undefined)
},
onSuccess: (user) => {
setUser(user)
toast.success("登录成功")
},
onError: (error: Error) => {
const errorMsg = error.message || ""
if (errorMsg.startsWith("need_email_code:")) {
const emailMasked = errorMsg.substring("need_email_code:".length)
setLoginCodeTip(
<>
已向您的安全邮箱 <span className="font-medium text-foreground">{emailMasked}</span> 发送了登录验证码。
</>
)
setShowLoginCodeInput(true)
setLoginCooldown(60)
toast.success("登录验证码已发送至您的邮箱,请注意查收")
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
return
}
if (errorMsg.startsWith("smtp_invalid:")) {
const tip = errorMsg.substring("smtp_invalid:".length)
setLoginCodeTip("请输入您的登录验证码。")
setShowLoginCodeInput(true)
setLoginCooldown(0)
toast.warning(tip)
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
return
}
setErrorMessage(errorMsg || "登录失败,请重试")
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
},
})
const handlePasswordLogin = () => {
setErrorMessage("")
const trimmedUsername = username.trim()
if (!trimmedUsername || !password) {
toast.error("邮箱/用户名或密码未填写完整", {
description: "请先输入邮箱/用户名和密码后再登录",
})
return
}
if (capEnabled && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
}
loginMutation.mutate({
username: trimmedUsername,
password,
code: showLoginCodeInput ? code.trim() : undefined,
})
}
const handleResendLoginCode = () => {
setCode("")
loginMutation.mutate({
username: username.trim(),
password,
})
}
const handleOAuthLogin = async (sourceName: string) => {
try {
setErrorMessage("")
persistRedirectTarget(searchParams)
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName)
window.location.href = authorize_url
} catch (error) {
toast.error(error instanceof Error ? error.message : "第三方登录失败")
}
}
const handleCapToken = (token: string) => {
capTokenRef.current = token
setCapReady(true)
setCapError(false)
}
const handleCapError = () => {
capTokenRef.current = null
setCapReady(false)
setCapError(true)
}
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true)
const oidcLoginEnabled = configBool(publicConfigQuery.data?.oidc_login_enabled, true)
const authSources = oidcLoginEnabled ? (authSourcesQuery.data ?? []) : []
const loginDisabled =
!passwordLoginEnabled ||
loginMutation.isPending ||
(capEnabled && capAutoSolve && !capReady && !capError)
if (publicConfigQuery.isPending) {
return (
<div className="flex items-center justify-center py-24">
<Spinner />
</div>
)
}
if (showLoginCodeInput) {
return (
<OTPForm
code={code}
setCode={setCode}
loginCodeTip={loginCodeTip}
loginCooldown={loginCooldown}
isPending={loginMutation.isPending}
onResend={handleResendLoginCode}
onSubmit={handlePasswordLogin}
/>
)
}
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<AuthHeading
siteName={publicConfigQuery.data?.site_name}
title="登录到您的账号"
description="欢迎回来,请输入您的账号信息继续。"
/>
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
<FieldGroup className="gap-4 [@media(max-height:700px)]:gap-3">
<Field className="gap-1.5">
<FieldLabel htmlFor="username">
邮箱或用户名 <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="输入邮箱或用户名"
autoComplete="username"
className="h-10 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor="password">
密码 <span className="text-destructive">*</span>
</FieldLabel>
<div className="relative">
<Input
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
type={showPassword ? "text" : "password"}
placeholder="输入您的密码"
autoComplete="current-password"
className="h-10 pr-11 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassword((visible) => !visible)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</Button>
</div>
</Field>
</FieldGroup>
{/* Cap 人机验证 */}
{capEnabled && (
<CapWidget
key={capResetKey}
onToken={handleCapToken}
onError={handleCapError}
autoStart={capAutoSolve}
/>
)}
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
<Button
type="button"
className="h-10 w-full [@media(max-height:700px)]:h-9"
variant="auth"
onClick={handlePasswordLogin}
disabled={loginDisabled}
>
{loginMutation.isPending ? (
<>
<Spinner />
登录中...
</>
) : (
"登录"
)}
</Button>
</div>
{authSources.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">或者使用</span>
<Separator className="flex-1" />
</div>
<div className="grid gap-2">
{authSources.map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="h-10 [@media(max-height:700px)]:h-9"
onClick={() => void handleOAuthLogin(source.name)}
>
{source.display_name || source.name} 登录
</Button>
))}
</div>
</div>
) : null}
{registrationEnabled && (
<div className="text-center text-sm text-muted-foreground">
还没有账号?{" "}
<Link href="/register" className="font-medium text-foreground underline underline-offset-4">
立即注册
</Link>
</div>
)}
</div>
)
}
@@ -0,0 +1,181 @@
"use client"
import {useCallback, useEffect, useRef, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {useRouter, useSearchParams} from "next/navigation"
import {toast} from "sonner"
import {Spinner} from "@/components/ui/spinner"
import {LoginForm} from "@/components/auth/login-form"
import {AuthShell} from "@/components/auth/auth-shell"
import {Check} from "lucide-react"
import {AuthService} from "@/lib/services/auth"
import {useAuth} from "@/components/providers/auth-provider"
import {safeRedirectTarget} from "@/lib/utils"
/**
* 登录页面组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginPage />
* ```
* @returns {React.ReactNode} 登录页面组件
*/
export function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, loading, setUser } = useAuth()
const [showOTP, setShowOTP] = useState(false)
/* 处理OAuth回调 */
const isOAuthCallback = !!(searchParams.get('state') && searchParams.get('code'))
const [isProcessingCallback, setIsProcessingCallback] = useState(isOAuthCallback)
const isCheckingSession = !isOAuthCallback && loading
const [loginSuccess, setLoginSuccess] = useState(false)
const redirectedRef = useRef(false)
const callbackProcessedRef = useRef(false)
const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl')
const storedRedirect = sessionStorage.getItem('redirect_after_login')
const target = callbackUrl || storedRedirect || '/home'
if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login')
}
return safeRedirectTarget(target)
}, [searchParams])
const resolveRedirectTargetRef = useRef(resolveRedirectTarget)
useEffect(() => {
resolveRedirectTargetRef.current = resolveRedirectTarget
}, [resolveRedirectTarget])
/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if ((state && code) || loading || !user) {
return
}
if (!redirectedRef.current) {
redirectedRef.current = true
router.replace(resolveRedirectTargetRef.current())
}
}, [loading, router, searchParams, user])
/* 回调逻辑 */
useEffect(() => {
const handleOAuthCallback = async () => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if (state && code) {
if (callbackProcessedRef.current) return
callbackProcessedRef.current = true
setIsProcessingCallback(true)
try {
const result = await AuthService.handleCallback({ state, code })
if (result.status === "need_bind") {
toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。")
setIsProcessingCallback(false)
router.replace('/login')
return
}
if (result.user) {
setUser(result.user)
}
setLoginSuccess(true)
toast.success(result.status === "bound" ? "绑定成功" : "登录成功")
setTimeout(() => {
if (!redirectedRef.current) {
redirectedRef.current = true
router.replace(resolveRedirectTargetRef.current())
}
}, 1500)
} catch (error) {
console.error('OAuth callback error:', error)
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
setIsProcessingCallback(false)
router.replace('/login')
}
}
}
handleOAuthCallback()
}, [router, searchParams, setUser])
return (
<AuthShell wide={showOTP}>
<div className="w-full">
<AnimatePresence mode="wait">
{isProcessingCallback || isCheckingSession ? (
<motion.div
key={isProcessingCallback ? "processing" : "session-check"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full"
>
{isCheckingSession ? (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<div className="relative">
<Spinner className="size-8" />
</div>
<div className="flex flex-col gap-2 text-center">
<h3 className="font-semibold tracking-tight text-foreground">正在检查登录状态</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
</div>
</div>
) : loginSuccess ? (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/20"
>
<Check className="size-6" strokeWidth={3} />
</motion.div>
<div className="flex flex-col gap-2 text-center">
<h3 className="font-semibold tracking-tight text-foreground">登录成功</h3>
<p className="text-xs text-muted-foreground">正在跳转至控制台...</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<div className="relative">
<Spinner className="size-8" />
</div>
<div className="flex flex-col gap-2 text-center">
<h3 className="font-semibold tracking-tight text-foreground">正在验证凭据</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在为您建立安全会话...</p>
</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="login-form-wrapper"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full"
>
<LoginForm onOTPStateChange={setShowOTP} />
</motion.div>
)}
</AnimatePresence>
</div>
</AuthShell>
)
}
@@ -0,0 +1,103 @@
"use client"
import * as React from "react"
import {RefreshCwIcon} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Field, FieldLabel} from "@/components/ui/field"
import {InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot,} from "@/components/ui/input-otp"
import {AuthHeading} from "@/components/auth/auth-shell"
import {cn} from "@/lib/utils"
interface OTPFormProps {
code: string
setCode: (val: string) => void
loginCodeTip: React.ReactNode
loginCooldown: number
isPending: boolean
onResend: () => void
onSubmit: () => void
}
export function OTPForm({
code,
setCode,
loginCodeTip,
loginCooldown,
isPending,
onResend,
onSubmit,
}: OTPFormProps) {
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<AuthHeading
title="验证您的登录"
description="输入发送到安全邮箱的 6 位验证码。"
/>
{loginCodeTip ? (
<p className="text-sm leading-6 text-muted-foreground">{loginCodeTip}</p>
) : null}
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
<Field className="gap-3">
<div className="flex items-center justify-between">
<FieldLabel htmlFor="otp-verification" className="text-sm font-medium">
验证码
</FieldLabel>
<Button
variant="outline"
size="sm"
type="button"
onClick={onResend}
disabled={loginCooldown > 0 || isPending}
className="h-8 text-xs"
>
<RefreshCwIcon className={cn(isPending && "animate-spin")} />
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"}
</Button>
</div>
<div className="flex justify-start">
<InputOTP
maxLength={6}
id="otp-verification"
required
value={code}
onChange={setCode}
onComplete={onSubmit}
disabled={isPending}
>
<InputOTPGroup className="*:data-[slot=input-otp-slot]:h-12 *:data-[slot=input-otp-slot]:w-11 *:data-[slot=input-otp-slot]:text-xl">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator className="mx-2" />
<InputOTPGroup className="*:data-[slot=input-otp-slot]:h-12 *:data-[slot=input-otp-slot]:w-11 *:data-[slot=input-otp-slot]:text-xl">
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</Field>
<Button
type="button"
className="h-10 w-full [@media(max-height:700px)]:h-9"
variant="auth"
onClick={onSubmit}
disabled={isPending || code.length < 6}
>
{isPending ? "验证中..." : "验证"}
</Button>
<div className="text-center text-sm text-muted-foreground">
遇到登录问题?{" "}
<a
href="#"
className="underline underline-offset-4 transition-colors hover:text-primary"
>
联系客服
</a>
</div>
</div>
</div>
)
}
@@ -0,0 +1,376 @@
"use client"
import {useEffect, useMemo, useRef, useState} from "react"
import {useMutation, useQuery} from "@tanstack/react-query"
import {useRouter, useSearchParams} from "next/navigation"
import {toast} from "sonner"
import Link from "next/link"
import {useAuth} from "@/components/providers/auth-provider"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Spinner} from "@/components/ui/spinner"
import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
import {AuthHeading} from "@/components/auth/auth-shell"
import {CapWidget} from "@/components/auth/cap-widget"
import {AuthService} from "@/lib/services/auth"
import {ConfigService} from "@/lib/services/config"
import type {RegisterRequest} from "@/lib/services/auth/types"
import {safeRedirectTarget} from "@/lib/utils"
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
const callbackUrl = searchParams.get("callbackUrl")
const storedRedirect =
typeof window === "undefined"
? null
: sessionStorage.getItem("redirect_after_login")
return safeRedirectTarget(callbackUrl || storedRedirect || "/home")
}
function configBool(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback
return value === "true"
}
export function RegisterForm() {
const router = useRouter()
const searchParams = useSearchParams()
const { setUser } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [nickname, setNickname] = useState("")
const [email, setEmail] = useState("")
const [code, setCode] = useState("")
const [registerCooldown, setRegisterCooldown] = useState(0)
const [errorMessage, setErrorMessage] = useState("")
useEffect(() => {
if (registerCooldown > 0) {
const timer = setTimeout(() => setRegisterCooldown(registerCooldown - 1), 1000)
return () => clearTimeout(timer)
}
}, [registerCooldown])
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryFn: () => ConfigService.getPublicConfig(),
})
const redirectTarget = useMemo(
() => getRedirectTarget(searchParams),
[searchParams],
)
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false)
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>('send_email_code')
// 监听 emailRegisterEnabled 改变初始 scope
useEffect(() => {
setCapScope(emailRegisterEnabled ? 'send_email_code' : 'register')
}, [emailRegisterEnabled])
const capTokenRef = useRef<string | null>(null)
const [capReady, setCapReady] = useState(false)
const [capError, setCapError] = useState(false)
const [capResetKey, setCapResetKey] = useState(0)
const handleCapToken = (token: string) => {
capTokenRef.current = token
setCapReady(true)
setCapError(false)
}
const handleCapError = () => {
capTokenRef.current = null
setCapReady(false)
setCapError(true)
}
// Redirect to login if registration is closed
useEffect(() => {
if (publicConfigQuery.isSuccess && !registrationEnabled) {
toast.error("系统注册功能已关闭")
router.replace("/login")
}
}, [publicConfigQuery.isSuccess, registrationEnabled, router])
const registerMutation = useMutation({
mutationFn: (req: RegisterRequest) => {
const headers: Record<string, string> = {}
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
capTokenRef.current = null
setCapReady(false)
}
return AuthService.register(req, Object.keys(headers).length ? headers : undefined)
},
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
toast.success("注册并登录成功")
},
onError: (error: Error) => {
setErrorMessage(error.message || "注册失败,请重试")
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
},
})
const sendRegisterCodeMutation = useMutation({
mutationFn: (targetEmail: string) => {
const headers: Record<string, string> = {}
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
capTokenRef.current = null
setCapReady(false)
}
return AuthService.sendEmailCode(targetEmail, "register", Object.keys(headers).length ? headers : undefined)
},
onSuccess: () => {
setRegisterCooldown(60)
toast.success("验证码已发送至您的邮箱,请查收")
if (capEnabled) {
setCapScope('register')
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
},
onError: (error: Error) => {
toast.error(error.message || "发送验证码失败,请重试")
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
}
},
})
const registerDisabled =
registerMutation.isPending ||
(capEnabled && capScope === 'register' && capAutoSolve && !capReady && !capError)
const sendCodeDisabled =
registerCooldown > 0 ||
sendRegisterCodeMutation.isPending ||
(capEnabled && capScope === 'send_email_code' && capAutoSolve && !capReady && !capError)
const handleSendRegisterCode = () => {
const trimmedEmail = email.trim()
if (!trimmedEmail) {
toast.error("请先输入邮箱地址")
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(trimmedEmail)) {
toast.error("请输入有效的邮箱地址")
return
}
if (capEnabled && capScope === 'send_email_code' && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
}
sendRegisterCodeMutation.mutate(trimmedEmail)
}
const handleRegister = () => {
setErrorMessage("")
if (!username.trim() || !password) {
toast.error("用户名和密码不能为空")
return
}
if (password.length < 8) {
toast.error("密码长度不能少于 8 位")
return
}
const trimmedEmail = email.trim()
if (!trimmedEmail) {
toast.error("邮箱不能为空")
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(trimmedEmail)) {
toast.error("请输入有效的邮箱地址")
return
}
if (emailRegisterEnabled && !code.trim()) {
toast.error("验证码不能为空")
return
}
if (capEnabled && capScope === 'register' && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
}
registerMutation.mutate({
username: username.trim(),
password,
nickname: nickname.trim() || undefined,
email: trimmedEmail,
code: code.trim() || undefined,
})
}
if (publicConfigQuery.isPending) {
return (
<div className="flex items-center justify-center py-24">
<Spinner />
</div>
)
}
if (!registrationEnabled) {
return null
}
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<AuthHeading
siteName={publicConfigQuery.data?.site_name}
title="创建您的账号"
description="填写以下信息,开始使用平台服务。"
/>
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
<FieldGroup className="gap-4 [@media(min-width:500px)]:grid [@media(min-width:500px)]:grid-cols-2 [@media(max-height:700px)]:gap-3">
<Field className="gap-1.5">
<FieldLabel htmlFor="username">用户名 <span className="text-destructive">*</span></FieldLabel>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
autoComplete="username"
className="h-10 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor="nickname">
昵称
<span className="ml-1 text-xs font-normal text-muted-foreground">(可选)</span>
</FieldLabel>
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
autoComplete="nickname"
className="h-10 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor="email">电子邮箱 <span className="text-destructive">*</span></FieldLabel>
<Input
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="请输入电子邮箱"
autoComplete="email"
className="h-10 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor="password">密码 <span className="text-destructive">*</span></FieldLabel>
<Input
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="请输入密码(至少 8 位)"
autoComplete="new-password"
className="h-10 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
/>
</Field>
{emailRegisterEnabled && (
<Field className="gap-1.5 [@media(min-width:500px)]:col-span-2">
<FieldLabel htmlFor="code">邮箱验证码 <span className="text-destructive">*</span></FieldLabel>
<div className="flex gap-2">
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入 6 位邮箱验证码"
maxLength={6}
className="h-10 flex-1 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
/>
<Button
type="button"
variant="outline"
onClick={handleSendRegisterCode}
disabled={sendCodeDisabled}
className="h-10 w-[120px] text-xs [@media(max-height:700px)]:h-9"
>
{registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"}
</Button>
</div>
</Field>
)}
</FieldGroup>
{capEnabled && (
<CapWidget
key={capResetKey}
scope={capScope}
onToken={handleCapToken}
onError={handleCapError}
autoStart={capAutoSolve}
/>
)}
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
<Button
type="button"
className="h-10 w-full [@media(max-height:700px)]:h-9"
variant="auth"
onClick={handleRegister}
disabled={registerDisabled}
>
{registerMutation.isPending ? (
<>
<Spinner />
注册中...
</>
) : (
"创建账号"
)}
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
已经有账号?{" "}
<Link href="/login" className="font-medium text-foreground underline underline-offset-4">
返回登录
</Link>
</div>
</div>
)
}
@@ -0,0 +1,12 @@
"use client"
import {RegisterForm} from "@/components/auth/register-form"
import {AuthShell} from "@/components/auth/auth-shell"
export function RegisterPage() {
return (
<AuthShell wide>
<RegisterForm />
</AuthShell>
)
}
@@ -0,0 +1,67 @@
"use client"
import type {ReactNode} from "react"
import {Loader2} from "lucide-react"
import {ErrorPage} from "@/components/layout/error"
import {useUser} from "@/contexts/user-context"
type RequireAuthProps = {
children: ReactNode
fallback?: ReactNode
minHeightClassName?: string
}
/**
* Guards page content until the session user is available.
* Redirect to login is handled by the parent layout; this only covers the content slot.
*/
export function RequireAuth({
children,
fallback,
minHeightClassName = "min-h-[400px]",
}: RequireAuthProps) {
const {user, loading} = useUser()
if (loading) {
return fallback ?? (
<div className={`flex items-center justify-center ${minHeightClassName}`}>
<Loader2 className="size-6 animate-spin text-primary" />
</div>
)
}
if (!user) {
return null
}
return <>{children}</>
}
type RequireAdminAuthProps = {
children: ReactNode
}
/** Guards admin routes after the shared shell has rendered. */
export function RequireAdminAuth({children}: RequireAdminAuthProps) {
const {user, loading} = useUser()
if (loading) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
)
}
if (!user?.is_admin) {
return (
<ErrorPage
title="访问被拒绝"
message="您没有权限访问此页面"
/>
)
}
return <>{children}</>
}
@@ -0,0 +1,269 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
import {
DocsTable,
DocsTableBody,
DocsTableCell,
DocsTableHead,
DocsTableHeader,
DocsTableRow,
} from "@/components/ui/docs-table"
export const DOCS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: "api-specs",
title: "1. 接口规范与鉴权说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明</p>
</div>
<h3 id="1-1-response-format" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.1 统一响应格式</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">字段</DocsTableHead>
<DocsTableHead className="w-[100px]">类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">error_msg</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>错误信息。请求成功时为空字符串 `&quot;&quot;`,失败时包含错误详情描述。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>接口返回的具体数据内容。请求失败或无数据返回时为 `null`。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="mt-2">成功响应示例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
"username": "ryan",
"nickname": "Ryan"
}
}`}
language="json"
/>
<p className="mt-2">失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language="json"
/>
<h3 id="1-2-authentication" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.2 鉴权方式</h3>
<p>除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session Cookie 会话保持登录。</li>
<li><strong>AccessToken 令牌:</strong>供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。</li>
</ul>
<div className="bg-muted border rounded-xl p-4 mt-2 space-y-2">
<p className="font-bold text-xs">支持携带令牌的请求头格式(二选一):</p>
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">Authorization: Bearer at_xxx</code></li>
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">X-Access-Token: at_xxx</code></li>
</ul>
</div>
</div>
),
children: [
{ value: "1-1-response-format", title: "1.1 统一响应格式" },
{ value: "1-2-authentication", title: "1.2 鉴权方式" },
]
},
{
value: "auth-apis",
title: "2. 用户与认证接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="2-1-register" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 用户注册</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/register</code></p>
<p><strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">nickname</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="2-2-login" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.2 密码登录</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/login</code></p>
<p><strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="2-3-logout" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.3 退出登录</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/logout</code></p>
<p><strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。</p>
<h3 id="2-4-profile" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.4 获取个人资料</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/self</code></p>
<p><strong>说明:</strong>获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。</p>
</div>
),
children: [
{ value: "2-1-register", title: "2.1 用户注册" },
{ value: "2-2-login", title: "2.2 密码登录" },
{ value: "2-3-logout", title: "2.3 退出登录" },
{ value: "2-4-profile", title: "2.4 获取个人资料" },
]
},
{
value: "token-apis",
title: "3. 个人访问令牌 (AccessToken) 接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4">
<p className="text-muted-foreground m-0">AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。</p>
</div>
<h3 id="3-1-list-token" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 获取令牌列表</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
<p><strong>说明:</strong>查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。</p>
<h3 id="3-2-create-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.2 新建访问令牌</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
<p><strong>参数:</strong>JSON Body <code className="bg-muted px-1.5 rounded text-xs font-mono">{`{"name": "token名称", "is_admin": false}`}</code></p>
<p><strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。</p>
<p className="mt-1 text-xs text-muted-foreground"><code className="bg-muted px-1 rounded">is_admin</code>(可选,默认 <code className="bg-muted px-1 rounded">false</code>):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问 <code className="bg-muted px-1 rounded">/admin/**</code> 端点。</p>
<p className="mt-2">成功返回样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
"record": {
"id": 5,
"user_id": 1,
"name": "my-dev-key",
"masked_token": "at_628d...29c9",
"is_admin": false,
"created_at": "2026-06-07T21:30:00+08:00"
}
}
}`}
language="json"
/>
<h3 id="3-3-delete-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.3 撤销/删除令牌</h3>
<p><strong>接口:</strong>DELETE <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id</code></p>
<p><strong>说明:</strong>通过 ID 物理删除对应访问令牌,该令牌将立即失效。</p>
<h3 id="3-4-rotate-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.4 轮换令牌密钥</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id/rotate</code></p>
<p><strong>说明:</strong>轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,令牌名称与 ID 保持一致。</p>
</div>
),
children: [
{ value: "3-1-list-token", title: "3.1 获取令牌列表" },
{ value: "3-2-create-token", title: "3.2 新建访问令牌" },
{ value: "3-3-delete-token", title: "3.3 撤销/删除令牌" },
{ value: "3-4-rotate-token", title: "3.4 轮换令牌密钥" },
]
},
{
value: "config-apis",
title: "4. 公共配置与管理接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="4-1-public-config" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">4.1 公共系统配置</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/config/public</code></p>
<p><strong>说明:</strong>无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。</p>
<p className="mt-2">返回数据结构样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "Wavelet",
"registration_enabled": "true",
"password_login_enabled": "true",
"password_register_enabled": "true",
"oidc_login_enabled": "true"
}
}`}
language="json"
/>
<h3 id="4-2-admin-configs" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">4.2 系统配置项 CRUD (管理员)</h3>
<p><strong>说明:</strong>用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>获取配置列表:</strong>GET <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs?type=system</code></li>
<li><strong>新建配置项:</strong>POST <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs</code></li>
<li><strong>修改指定配置值:</strong>PUT <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
<li><strong>删除配置项:</strong>DELETE <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
</ul>
</div>
),
children: [
{ value: "4-1-public-config", title: "4.1 公共系统配置" },
{ value: "4-2-admin-configs", title: "4.2 系统配置项 CRUD (管理员)" },
]
}
]
@@ -0,0 +1,138 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
/**
* ------------------------------------------------------------------
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: "quick-start",
title: "1. 快速开始",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明</p>
</div>
<ul className="list-disc pl-5 space-y-2">
<li><strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端</li>
<li><strong>认证体系:</strong>支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定</li>
<li><strong>访问令牌:</strong>提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API</li>
<li><strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪</li>
</ul>
</div>
)
},
{
value: "auth-security",
title: "2. 身份认证与安全设置",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3 id="2-1-login" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 常规账号密码认证</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>自主注册与密码登录:</strong>支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">bcrypt</code> 高强度加盐哈希存储。</li>
<li><strong>系统开关控制:</strong>管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。</li>
</ul>
<h3 id="2-2-oidc" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.2 第三方 OIDC 认证源</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className="list-decimal pl-4 md:pl-5 space-y-1">
<li>进入 <strong>设置 / 个人资料</strong> 页面。</li>
<li>在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。</li>
<li>绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。</li>
</ol>
</div>
),
children: [
{ value: "2-1-login", title: "2.1 常规账号密码认证" },
{ value: "2-2-oidc", title: "2.2 第三方 OIDC 认证源" },
]
},
{
value: "access-token",
title: "3. 个人访问令牌 (AccessToken) 接口对接",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。</p>
<h3 id="3-1-generation" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 令牌生成与存储规范</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值 (形如 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">at_xxx</code>) 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。</li>
<li><strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。</li>
</ul>
<h3 id="3-2-usage" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.2 携带 Header 进行 API 调用</h3>
<p>您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:</p>
<div className="space-y-2">
<p className="font-semibold text-xs text-muted-foreground">方式一:Authorization Bearer 头</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
Authorization: Bearer at_628d022b7a95e26b...`}
language="http"
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-xs text-muted-foreground">方式二:X-Access-Token 自定义头</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
X-Access-Token: at_628d022b7a95e26b...`}
language="http"
/>
</div>
</div>
),
children: [
{ value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
{ value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
]
},
{
value: "config-system",
title: "4. 动态系统配置项",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。</li>
<li><strong>核心系统配置项说明:</strong>
<ul className="list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground">
<li><code className="bg-muted px-1 rounded text-xs font-mono">site_name</code>:平台展示名称</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">password_login_enabled</code>:密码登录开关</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">registration_enabled</code>:用户自主注册开关</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">max_api_keys_per_user</code>:普通用户创建令牌的最大数限制 (默认5)</li>
</ul>
</li>
</ul>
</div>
)
},
{
value: "worker-scheduler",
title: "5. 异步任务与定时调度",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker 服务解耦复杂计算或高延迟IO:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron 表达式配置,定时将待执行任务推送到 Redis 队列中。</li>
<li><strong>多优先级 Worker (CMD worker):</strong>基于 Asynq 驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。</li>
</ul>
</div>
)
},
{
value: "tracing-metrics",
title: "6. 链路追踪与结构化日志",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>OpenTelemetry Tracing:</strong>自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。</li>
<li><strong>Zap 结构化日志:</strong>将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。</li>
</ul>
</div>
)
}
]
@@ -0,0 +1,411 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {ArrowLeft, Calendar, Check, Copy, FileText} from "lucide-react"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
import {motion, useScroll, useSpring} from "motion/react"
import type {PolicySection} from "./types"
interface LegalPageLayoutProps {
title: string
lastUpdated: string
sections: PolicySection[]
description?: React.ReactNode
}
export function LegalPageLayout({
title,
lastUpdated,
sections,
description,
}: LegalPageLayoutProps) {
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
const [hasCopied, setHasCopied] = React.useState(false)
const { scrollYProgress } = useScroll()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
})
const allSectionIds = React.useMemo(() => {
return sections.flatMap(section => [
section.value,
...(section.children?.map(child => child.value) || [])
])
}, [sections])
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id)
}
})
},
{ rootMargin: "-20% 0px -35% 0px" }
)
allSectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
return () => observer.disconnect()
}, [sections, allSectionIds])
const convertInlineNodes = (nodes: ChildNode[]): string => {
return nodes.map((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ""
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ""
}
const element = node as HTMLElement
const tagName = element.tagName.toLowerCase()
if (tagName === "code") {
return `\`${element.textContent?.trim() || ""}\``
}
if (tagName === "strong" || tagName === "b") {
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
}
if (tagName === "em" || tagName === "i") {
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
}
if (tagName === "a") {
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
const href = element.getAttribute("href") || ""
return href ? `[${text}](${href})` : text
}
if (tagName === "br") {
return "\n"
}
return convertInlineNodes(Array.from(element.childNodes))
}).join("")
}
const convertList = (
list: HTMLOListElement | HTMLUListElement,
indentLevel: number
): string => {
const isOrdered = list.tagName.toLowerCase() === "ol"
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
return items.map((item, index) => {
const prefix = isOrdered ? `${index + 1}. ` : "- "
const indent = " ".repeat(indentLevel)
const parts: string[] = []
const itemElement = item as HTMLElement
const childNodes = Array.from(itemElement.childNodes)
const nestedLists: HTMLElement[] = []
childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const childElement = child as HTMLElement
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
nestedLists.push(childElement)
return
}
}
parts.push(convertInlineNodes([child]).trim())
})
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
}).join("\n")
}
const convertTable = (table: HTMLTableElement): string => {
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
)
if (!rows.length) return ""
const header = rows[0]
const body = rows.slice(1)
const headerLine = `| ${header.join(" | ")} |`
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
return [headerLine, separatorLine, ...bodyLines].join("\n")
}
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
const tagName = node.tagName.toLowerCase()
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
if (tagName === "pre") {
const code = node.textContent || ""
return `\`\`\`\n${code.trim()}\n\`\`\``
}
if (tagName === "table") return convertTable(node as HTMLTableElement)
if (tagName === "div") {
const hasTable = node.querySelector("table")
const hasList = node.querySelector("ul,ol")
const hasPre = node.querySelector("pre")
if (hasTable || hasList || hasPre) {
return Array.from(node.children)
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
.filter(Boolean)
.join("\n\n")
}
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
return text
}
return convertInlineNodes(Array.from(node.childNodes)).trim()
}
const buildMarkdown = (): string => {
const contentRoot = contentRef.current
if (!contentRoot) return ""
const lines: string[] = []
lines.push(`# ${title}`)
lines.push("")
lines.push(`最后更新:${lastUpdated}`)
if (description) {
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
if (descriptionElement) {
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
if (descriptionText) {
lines.push("")
lines.push(descriptionText)
}
}
}
lines.push("")
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
sectionNodes.forEach((section) => {
const heading = section.querySelector("h2")
if (heading?.textContent) {
lines.push(`## ${heading.textContent.trim()}`)
lines.push("")
}
const prose = section.querySelector("[data-docs-content]")
if (prose) {
const blocks = Array.from(prose.children)
.map((child) => convertBlockNode(child as HTMLElement, 0))
.filter(Boolean)
if (blocks.length) {
lines.push(blocks.join("\n\n"))
lines.push("")
}
}
})
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
}
const handleCopyMarkdown = async () => {
const markdown = buildMarkdown()
if (!markdown) return
try {
await navigator.clipboard.writeText(markdown)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
} catch {
const textArea = document.createElement("textarea")
textArea.value = markdown
textArea.style.position = "fixed"
textArea.style.opacity = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
}
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
const element = document.getElementById(id)
if (element) {
const offset = 80
const bodyRect = document.body.getBoundingClientRect().top
const elementRect = element.getBoundingClientRect().top
const elementPosition = elementRect - bodyRect
const offsetPosition = elementPosition - offset
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
})
}
}
return (
<div className="min-h-screen bg-background relative selection:bg-primary/20">
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX }}
/>
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
</div>
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
<header className="mb-8 md:mb-12">
<Link href="/" className="inline-block mb-8">
<Button
variant="ghost"
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
>
<ArrowLeft className="w-4 h-4" />
返回首页
</Button>
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
{title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<FileText className="w-3.5 h-3.5" />
{sections.length} 个内容
</span>
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<Calendar className="w-3.5 h-3.5" />
最后更新:{lastUpdated}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyMarkdown}
className="h-8 gap-2 text-xs"
>
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{hasCopied ? "已复制" : "复制 Markdown"}
</Button>
</div>
{description && (
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
{description}
</div>
</div>
)}
</motion.div>
</header>
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-24">
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.value} className="space-y-1">
<a
href={`#${ section.value }`}
onClick={(e) => scrollToSection(e, section.value)}
className={cn(
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
activeSection === section.value
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<span className="truncate">{section.title}</span>
{activeSection === section.value && (
<motion.div
layoutId="active-dot"
className="w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</a>
{section.children && (
<div className="pl-4 space-y-1">
{section.children.map((child) => (
<a
key={child.value}
href={`#${ child.value }`}
onClick={(e) => scrollToSection(e, child.value)}
className={cn(
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
activeSection === child.value
? "text-primary font-medium bg-primary/5"
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
)}
>
{child.title}
</a>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
</aside>
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
{sections.map((section, index) => (
<motion.section
key={section.value}
id={section.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "100px" }}
transition={{ duration: 0.3 }}
className="scroll-mt-24 group"
data-docs-section
>
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
{index + 1}
</span>
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
{section.title.replace(/^\d+\.\s*/, '')}
</h2>
</div>
<div
data-docs-content
className={cn(
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
"prose-p:text-muted-foreground prose-p:leading-7",
"prose-li:text-muted-foreground",
"prose-strong:text-foreground",
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
)}>
{section.content}
</div>
</motion.section>
))}
<div className="pt-16 border-t border-border mt-20">
<div className="bg-muted/30 rounded-2xl p-8 text-center">
<p className="text-muted-foreground text-sm mb-4">
对这些内容有疑问?
</p>
<div className="flex justify-center gap-4">
<Link href="https://github.com/Rain-kl/Wavelet/issues" target="_blank">
<Button variant="outline" className="h-9 text-xs">
联系支持
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div >
</div >
)
}
@@ -0,0 +1,100 @@
import {type PolicySection} from "./types"
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: "collection-details",
title: "1. 详细信息收集说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
<p className="mt-1 text-muted-foreground">当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。<strong>我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。</strong></p>
</div>
<div>
<span className="font-semibold text-foreground">1.2 服务与接口日志信息:</span>
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。</p>
</div>
</div>
</div>
),
},
{
value: "storage-security",
title: "2. 数据存储与安全保护",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>存储安全:</strong>用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。</li>
<li><strong>加密技术:</strong>敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。</li>
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。</li>
</ul>
</div>
),
},
{
value: "usage-rules",
title: "3. 信息使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们收集的信息将仅用于以下目的:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>身份识别:</strong>用于确认您的注册身份,展示您的个人中心及设置页面数据。</li>
<li><strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API 鉴权指令。</li>
<li><strong>安全风控:</strong>利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。</li>
</ul>
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。</p>
</div>
),
},
{
value: "sharing-disclosure",
title: "4. 信息共享与对外披露",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>4.1 共享原则:</strong>除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>事先获得您的明确授权或同意;</li>
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
</ul>
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。</p>
</div>
),
},
{
value: "user-rights",
title: "5. 您的权利与数据管理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">5.1 查阅与管理权:</span>
<p className="mt-1 text-muted-foreground">您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.2 删除与注销权:</span>
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。</p>
</div>
</div>
</div>
),
},
{
value: "policy-update",
title: "6. 政策更新与通知",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。</p>
</div>
),
},
]
@@ -0,0 +1,92 @@
import {type PolicySection} from "./types"
export const TERMS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: "contract-establishment",
title: "1. 缔约申明与服务综述",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。</p>
<p><strong>1.2 审慎阅读:</strong>本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。</p>
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
</div>
),
},
{
value: "service-definition",
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>责任自担:</strong>关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。</li>
</ul>
</div>
),
},
{
value: "account-specifications",
title: "3. 账号注册与使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>3.1 账号体系:</strong>用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。</p>
<p><strong>3.2 密码及令牌安全责任:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>密码安全:</strong>您应妥善保管您账户的登录密码。</li>
<li><strong>API Token 安全:</strong>个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。<strong>因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。</strong></li>
</ul>
</div>
),
},
{
value: "user-conduct",
title: "4. 用户行为准则(负面清单)",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
</ul>
</div>
<p><strong>处理规则:</strong>一旦发现您违反上述规定,平台运维主体有权不经通知<strong>立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。</strong></p>
</div>
),
},
{
value: "liability-limitation",
title: "5. 免责声明与不可抗力",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>5.1 基础免责:</strong>本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。</p>
<p><strong>5.2 技术服务中断:</strong>对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: "governing-law",
title: "6. 法律适用与争议解决",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>6.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。</p>
<p><strong>6.2 争议解决:</strong>若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。</p>
</div>
),
},
]
@@ -0,0 +1,11 @@
import {type ReactNode} from "react"
export type PolicySection = {
value: string
title: string
content: ReactNode
children?: {
value: string
title: string
}[]
}
@@ -0,0 +1,62 @@
"use client"
import * as React from "react"
import {ImageOff} from "lucide-react"
import {cn} from "@/lib/utils"
import {getFileUrl, type ImageQuality} from "@/lib/services/upload/utils"
type FileImagePreviewProps = {
fileId: string | number
alt: string
quality?: ImageQuality
className?: string
fallbackClassName?: string
variant?: "compact" | "default"
}
export function FileImagePreview({
fileId,
alt,
quality = "low",
className,
fallbackClassName,
variant = "default",
}: FileImagePreviewProps) {
const [failed, setFailed] = React.useState(false)
const src = getFileUrl(fileId, quality)
React.useEffect(() => {
setFailed(false)
}, [fileId, quality])
if (!src || failed) {
return (
<div
role="img"
aria-label={`${alt} 加载失败`}
className={cn(
"flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground",
fallbackClassName,
)}
>
<ImageOff className={cn("shrink-0 opacity-70", variant === "compact" ? "size-3.5" : "size-5")} />
{variant === "default" && (
<span className="text-[10px] leading-none">加载失败</span>
)}
</div>
)
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
className={className}
onError={() => setFailed(true)}
/>
)
}
@@ -0,0 +1,405 @@
import {useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Spinner} from "@/components/ui/spinner"
import {ErrorInline} from "@/components/layout/error"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
interface ManagePageProps<T> {
title: string
icon?: LucideIcon
data: T[]
loading: boolean
error: Error | null
onReload: () => void
/** 获取初始编辑数据 */
getInitialEditData: (item: T) => Partial<T>
onSave: (item: T, editData: Partial<T>) => Promise<void>
onDelete?: (item: T) => Promise<void>
/** 渲染表格 (Config-Driven) */
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
/** 渲染详情 */
renderDetail: (props: {
selected: T | null
hovered: T | null
editData: Partial<T>
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
onSave: () => void
saving: boolean
}) => React.ReactNode
/** 空状态图标 */
emptyIcon?: LucideIcon
emptyDescription?: string
loadingDescription?: string
getId: (item: T) => string | number
headerExtra?: React.ReactNode
}
export function ManagePage<T>({
title,
icon: Icon,
data,
loading,
error,
onReload,
getInitialEditData,
onSave,
onDelete,
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
getId,
headerExtra
}: ManagePageProps<T>) {
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
const [selectedItem, setSelectedItem] = useState<T | null>(null)
const [editData, setEditData] = useState<Partial<T>>({})
const [saving, setSaving] = useState(false)
const [deletingItem, setDeletingItem] = useState<T | null>(null)
/** 悬停处理 */
const handleHover = (item: T | null) => {
setHoveredItem(item)
}
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
}
setHoveredItem(null)
}
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
...prev,
[field]: value
}))
}
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
} finally {
setSaving(false)
}
}
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
const handleConfirmDelete = async () => {
if (!deletingItem || !onDelete) return
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
}
}
/** 渲染内容 */
const renderContent = () => {
if (loading && (!data || data.length === 0)) {
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
/>
</div>
)
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<ManageTable
data={data}
columns={columns}
selected={selectedItem}
hovered={hoveredItem}
onSelect={handleSelect}
onHover={handleHover}
onDelete={onDelete ? handleDeleteClick : undefined}
getId={getId}
/>
)
}
return (
<div className="py-6">
<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>
<div className="space-y-6">
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
</div>
{renderContent()}
</div>
<div>
{renderDetail({
selected: selectedItem,
hovered: hoveredItem,
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
/** 详情面板 props */
interface ManageDetailPanelProps {
title?: string
isEmpty: boolean
emptyDescription?: string
onSave?: () => void
saving?: boolean
children: React.ReactNode
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
isEmpty,
emptyDescription = "请选择配置查看详情",
onSave,
saving = false,
children
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
</div>
)
}
return (
<div className="space-y-4 sticky top-6">
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</div>
{onSave && (
<Button
onClick={onSave}
disabled={saving}
size="sm"
className="px-3 h-7 text-xs"
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
</Button>
)}
</div>
{children}
</div>
</div>
)
}
/** 表格面板 */
export function ManageTable<T>({
data,
columns,
selected,
hovered,
onSelect,
onHover,
onDelete,
getId
}: {
data: T[]
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
selected: T | null
hovered: T | null
onSelect: (item: T) => void
onHover: (item: T | null) => void
onDelete?: (item: T) => void
getId: (item: T) => string | number
}) {
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea className="w-full">
<div className="relative w-full">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow className="border-b border-dashed">
{columns.map((col, index) => (
<TableHead
key={index}
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.header}
</TableHead>
))}
{onDelete && <TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>}
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
{data.map((item) => {
const id = getId(item)
const isSelected = selected && getId(selected) === id
const isHovered = hovered && getId(hovered) === id
return (
<TableRow
key={id}
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
? 'bg-primary/5 hover:bg-primary/10'
: isHovered
? 'bg-gray-100 hover:bg-muted/50'
: 'hover:bg-gray-100'
}`}
onMouseEnter={() => onHover(item)}
onMouseLeave={() => onHover(null)}
onClick={() => onSelect(item)}
>
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.cell(item)}
</TableCell>
))}
{onDelete && (
<TableCell className="text-xs py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation()
onDelete(item)
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}
@@ -0,0 +1,108 @@
"use client"
import * as React from "react"
import {useState} from "react"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog"
import {InputOTP, InputOTPGroup, InputOTPSlot,} from "@/components/ui/input-otp"
import {Button} from "@/components/ui/button"
import {Spinner} from "@/components/ui/spinner"
interface PasswordDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: (password: string) => void
loading?: boolean
title?: string
description?: string
}
/**
* 密码输入对话框
*
* 用于敏感操作前的二次确认
*/
export function PasswordDialog({
isOpen,
onOpenChange,
onConfirm,
loading = false,
title = "请输入密码",
description = "为了您的账户安全,请输入6位认证密码进行验证"
}: PasswordDialogProps) {
const [password, setPassword] = useState("")
const handleConfirm = () => {
if (password.length === 6) {
onConfirm(password)
}
}
/* 当对话框关闭时重置密码*/
React.useEffect(() => {
if (!isOpen) {
setPassword("")
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="w-xs sm:w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<div className="py-2 flex justify-center">
<InputOTP
maxLength={6}
value={password}
onChange={setPassword}
disabled={loading}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 6 && !loading) {
handleConfirm()
}
}}
>
<InputOTPGroup className="gap-2">
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-8 text-xs"
>
取消
</Button>
<Button
type="button"
className="bg-primary hover:bg-primary/90 h-8 text-xs"
onClick={handleConfirm}
disabled={password.length !== 6 || loading}
>
{loading ? (
<>
<Spinner className="mr-2 h-4 w-4" />
验证中...
</>
) : (
"确认"
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,227 @@
"use client"
import * as React from "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"
import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCheck, Terminal, User} from "lucide-react"
import Link from "next/link"
export function HomeMain() {
const { user } = useUser()
const [isBannerVisible, setIsBannerVisible] = React.useState(true)
const quickLinks = [
{
title: "个人资料",
description: "管理您的个人账户信息及个性化配置",
icon: User,
url: "/settings/profile",
color: "text-blue-500",
bgColor: "bg-blue-500/10",
borderColor: "hover:border-blue-500/30",
},
{
title: "开发接口文档",
description: "查看开放平台的 RESTful 接口规格说明",
icon: FileText,
url: "/docs/api",
color: "text-emerald-500",
bgColor: "bg-emerald-500/10",
borderColor: "hover:border-emerald-500/30",
external: true,
},
{
title: "使用文档",
description: "学习如何集成 API 及日常操作帮助指南",
icon: HelpCircle,
url: "/docs/how-to-use",
color: "text-purple-500",
bgColor: "bg-purple-500/10",
borderColor: "hover:border-purple-500/30",
external: true,
},
]
const adminLinks = [
{
title: "系统设置",
description: "管理登录注册开关与认证源安全策略",
icon: Shield,
url: "/admin/settings",
color: "text-amber-500",
bgColor: "bg-amber-500/10",
borderColor: "hover:border-amber-500/30",
},
{
title: "全局系统配置",
description: "动态管理平台运行时核心配置参数",
icon: ShieldCheck,
url: "/admin/system",
color: "text-indigo-500",
bgColor: "bg-indigo-500/10",
borderColor: "hover:border-indigo-500/30",
},
{
title: "用户权限管理",
description: "集中查询并管理系统注册用户的启用状态",
icon: User,
url: "/admin/users",
color: "text-rose-500",
bgColor: "bg-rose-500/10",
borderColor: "hover:border-rose-500/30",
},
{
title: "后台任务调度",
description: "分发与观测系统异步定时任务的执行情况",
icon: Layers,
url: "/admin/tasks",
color: "text-teal-500",
bgColor: "bg-teal-500/10",
borderColor: "hover:border-teal-500/30",
},
{
title: "系统日志",
description: "查看异步任务执行日志与系统运行状态详情",
icon: Terminal,
url: "/admin/logs",
color: "text-cyan-500",
bgColor: "bg-cyan-500/10",
borderColor: "hover:border-cyan-500/30",
},
]
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-3 gap-4">
{quickLinks.map((link, idx) => (
<motion.div
key={link.title}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: idx * 0.05 }}
whileHover={{ y: -4 }}
className="h-full"
>
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
<CardHeader className="p-5">
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
<link.icon className="size-5" />
</div>
<CardTitle className="text-sm font-semibold mb-1 flex items-center gap-1">
{link.title}
{link.external && <ExternalLink className="size-3 text-muted-foreground" />}
</CardTitle>
<p className="text-xs text-muted-foreground leading-normal font-normal">
{link.description}
</p>
</CardHeader>
<div className="px-5 pb-5">
{link.external ? (
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
<Link href={link.url} target="_blank" rel="noopener noreferrer">
立即跳转 <ArrowRight className="size-3 ml-1" />
</Link>
</Button>
) : (
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
<Link href={link.url}>
立即进入 <ArrowRight className="size-3 ml-1" />
</Link>
</Button>
)}
</div>
</Card>
</motion.div>
))}
</div>
</div>
{/* 管理面板 (仅管理员可见) */}
{user?.is_admin && (
<div className="space-y-4 pt-2">
<h2 className="text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5">
<Shield className="size-5" />
后台管理控制台
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{adminLinks.map((link, idx) => (
<motion.div
key={link.title}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: (idx + 4) * 0.05 }}
whileHover={{ y: -4 }}
className="h-full"
>
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
<CardHeader className="p-5">
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
<link.icon className="size-5" />
</div>
<CardTitle className="text-sm font-semibold mb-1">
{link.title}
</CardTitle>
<p className="text-xs text-muted-foreground leading-normal font-normal">
{link.description}
</p>
</CardHeader>
<div className="px-5 pb-5">
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
<Link href={link.url}>
立即进入 <ArrowRight className="size-3 ml-1" />
</Link>
</Button>
</div>
</Card>
</motion.div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,426 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {motion} from "motion/react"
import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Shield, Trash2} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Badge} from "@/components/ui/badge"
import {Switch} from "@/components/ui/switch"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import type {CreateTokenResponse} from "@/lib/services/user"
import {UserService} from "@/lib/services/user"
import {useAuth} from "@/components/providers/auth-provider"
import {toast} from "sonner"
export function AccessTokenMain() {
const { user } = useAuth()
const queryClient = useQueryClient()
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
const [tokenName, setTokenName] = React.useState("")
const [tokenIsAdmin, setTokenIsAdmin] = React.useState(false)
const [copiedId, setCopiedId] = React.useState<number | null>(null)
const [newCreatedToken, setNewCreatedToken] = React.useState<CreateTokenResponse | null>(null)
// 获取 Token 列表
const accessTokensQuery = useQuery({
queryKey: ["user", "access-tokens"],
queryFn: () => UserService.getAccessTokens(),
})
// 创建 Token
const createTokenMutation = useMutation({
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
onSuccess: (data) => {
setNewCreatedToken(data)
setTokenName("")
setTokenIsAdmin(false)
setCreateDialogOpen(false)
setViewDialogOpen(true)
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
toast.success("访问令牌创建成功")
},
onError: (error: Error) => {
toast.error(error.message || "创建访问令牌失败")
},
})
// 删除 Token
const deleteTokenMutation = useMutation({
mutationFn: (id: number) => UserService.deleteAccessToken(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
toast.success("访问令牌已撤销")
},
onError: (error: Error) => {
toast.error(error.message || "删除访问令牌失败")
},
})
// 轮换 Token
const rotateTokenMutation = useMutation({
mutationFn: (id: number) => UserService.rotateAccessToken(id),
onSuccess: (data) => {
setNewCreatedToken(data)
setViewDialogOpen(true)
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
toast.success("访问令牌轮换成功,旧密钥已失效")
},
onError: (error: Error) => {
toast.error(error.message || "轮换访问令牌失败")
},
})
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault()
if (!tokenName.trim()) {
toast.error("请输入令牌名称")
return
}
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
}
const handleDeleteToken = (id: number, name: string) => {
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
deleteTokenMutation.mutate(id)
}
}
const handleRotateToken = (id: number, name: string) => {
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
rotateTokenMutation.mutate(id)
}
}
const handleCopyText = async (text: string, id: number) => {
try {
await navigator.clipboard.writeText(text)
setCopiedId(id)
toast.success("复制成功")
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error("复制失败")
}
}
const formatDate = (dateStr?: string) => {
if (!dateStr) return "未使用"
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
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 max-w-4xl mx-auto px-4"
>
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
<div className="flex items-center gap-4">
<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">个人访问令牌 (AccessToken)</h1>
<p className="text-sm text-muted-foreground">管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API</p>
</div>
</div>
<Button
type="button"
onClick={() => setCreateDialogOpen(true)}
variant={'secondary'}
>
<Plus className="mr-1.5 size-4" />
生成新令牌
</Button>
</div>
{/* 安全警告提示 */}
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed">
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<span className="font-bold">安全提示:</span>
<p className="text-muted-foreground">
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
</p>
</div>
</div>
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<CardTitle className="text-base font-semibold">活动令牌列表</CardTitle>
<CardDescription className="text-xs">当前可用的所有访问令牌</CardDescription>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{accessTokensQuery.isPending ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-indigo-500" />
</div>
) : (accessTokensQuery.data ?? []).length > 0 ? (
<div className="space-y-3">
{(accessTokensQuery.data ?? []).map((token) => (
<div
key={token.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">{token.name}</span>
{token.is_admin ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold">
<Shield className="size-2.5 mr-0.5" />
管理员
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold">
用户令牌
</Badge>
)}
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all">
{token.masked_token}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pt-1">
<span>创建于: {formatDate(token.created_at)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 sm:self-center">
<Button
type="button"
variant="outline"
size="sm"
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
onClick={() => handleCopyText(token.masked_token, token.id)}
>
{copiedId === token.id ? (
<Check className="size-3.5 mr-1 text-emerald-500" />
) : (
<Copy className="size-3.5 mr-1" />
)}
复制
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
onClick={() => handleRotateToken(token.id, token.name)}
disabled={rotateTokenMutation.isPending}
>
<RefreshCw className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`} />
轮换
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5"
onClick={() => handleDeleteToken(token.id, token.name)}
disabled={deleteTokenMutation.isPending}
>
<Trash2 className="size-3.5 mr-1" />
撤销
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
<Key className="size-8 text-muted-foreground/30" />
<span>您当前暂无生成任何访问令牌</span>
<Button
type="button"
variant="outline"
size="sm"
className="border-dashed"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
生成第一个令牌
</Button>
</div>
)}
</CardContent>
</Card>
{/* 创建令牌 Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleCreateToken}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="token-name" className="text-xs font-semibold">令牌名称</Label>
<Input
id="token-name"
placeholder="例如:my-development-key"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
disabled={createTokenMutation.isPending}
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
/>
</div>
{user?.is_admin && (
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 bg-muted/5">
<div className="space-y-0.5">
<Label htmlFor="token-admin" className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="size-3.5 text-rose-500" />
管理员权限
</Label>
<p className="text-[11px] text-muted-foreground leading-normal">
开启后此令牌可访问 /admin/** 管理端点,默认关闭
</p>
</div>
<Switch
id="token-admin"
checked={tokenIsAdmin}
onCheckedChange={setTokenIsAdmin}
disabled={createTokenMutation.isPending}
/>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCreateDialogOpen(false)}
disabled={createTokenMutation.isPending}
className="rounded-xl text-xs h-9 border-dashed"
>
取消
</Button>
<Button
type="submit"
disabled={createTokenMutation.isPending}
variant={'secondary'}
>
{createTokenMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
正在生成...
</>
) : (
"生成令牌"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 明文 Token 显示 Dialog (仅显示一次) */}
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
if (!open) {
setNewCreatedToken(null)
setViewDialogOpen(false)
}
}}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-1.5">
<Check className="size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5" />
令牌密钥已就绪
</DialogTitle>
</DialogHeader>
{newCreatedToken && (
<div className="space-y-4 py-2">
{/* 明文 Token 文本框 */}
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
{newCreatedToken.token}
</span>
<Button
type="button"
size="icon"
variant="outline"
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
>
{copiedId === 9999 ? (
<Check className="size-3.5 text-emerald-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
{/* 强提示 */}
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed">
<Info className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<span className="font-bold">重要提示:</span>
<p className="text-muted-foreground">
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
</p>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
onClick={() => {
setNewCreatedToken(null)
setViewDialogOpen(false)
}}
className="rounded-xl w-full"
>
我已经复制并妥善保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
)
}
@@ -0,0 +1,231 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {Check, Loader2, Monitor, Moon, Sun} from "lucide-react"
import {useTheme} from "next-themes"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {useCustomTheme} from "@/lib/theme"
import {cn} from "@/lib/utils"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
function ThemeModeSection() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const modes = [
{ id: "light", name: "明亮", icon: Sun },
{ id: "dark", name: "黑暗", icon: Moon },
{ id: "system", name: "自动", icon: Monitor }
]
if (!mounted) {
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
return (
<Button
key={mode.id}
variant="secondary"
size="sm"
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
const isActive = theme === mode.id
return (
<Button
key={mode.id}
variant={isActive ? "default" : "secondary"}
size="sm"
onClick={() => setTheme(mode.id)}
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
function InterfaceAppearanceSection() {
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
const { theme: mode } = useTheme()
const [mounted, setMounted] = React.useState(false)
const [switching, setSwitching] = React.useState<string | null>(null)
React.useEffect(() => {
setMounted(true)
}, [])
const handleSetTheme = (themeId: string) => {
if (switching === themeId) return
setSwitching(themeId)
try {
setCustomTheme(themeId)
const themeName = themes.find(t => t.id === themeId)?.name || themeId
toast.success(`外观已切换为 ${ themeName }`)
} catch {
toast.error("切换失败")
} finally {
setSwitching(null)
}
}
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
<p className="text-xs text-muted-foreground">
选择界面的配色方案和视觉风格
</p>
</div>
<div>
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
</div>
) : (
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{themes.map((theme) => {
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
const isSwitching = switching === theme.id
const colors = isDark ? theme.colors.dark : theme.colors.light
return (
<div
key={theme.id}
onClick={() => handleSetTheme(theme.id)}
className={cn(
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
isActive
? "border-primary ring-2 ring-primary/20"
: "border-border hover:border-border/50 hover:shadow-md"
)}
>
<div
className="aspect-[4/3] w-full relative"
style={{ backgroundColor: colors.background || colors['background'] }}
>
<div className="flex h-full p-2 gap-2">
<div
className="w-1/4 h-full rounded-sm opacity-80"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div className="flex-1 space-y-1.5 pt-1">
<div
className="h-1.5 w-1/3 rounded-full opacity-90"
style={{ backgroundColor: colors.primary || colors['primary'] }}
/>
<div
className="h-8 w-full rounded-sm opacity-40"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div
className="h-4 w-1/2 rounded-sm opacity-30"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
</div>
</div>
{isActive && !isSwitching && (
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="p-2 border-t border-border/50 bg-card">
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
{theme.name}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export function AppearanceMain() {
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<ThemeModeSection />
<InterfaceAppearanceSection />
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import {useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
const emptyForm: AuthSourceRequest = {
name: "",
type: "oidc",
display_name: "",
is_active: false,
client_id: "",
client_secret: "",
openid_discovery_url: "",
scopes: "openid profile email",
icon_url: "",
}
export function AuthSourceModal({
isOpen,
source,
onClose,
onChanged,
}: {
isOpen: boolean
source: AuthSource | null
onClose: () => void
onChanged: () => Promise<void>
}) {
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (isOpen) {
if (source) {
setForm({
name: source.name,
type: source.type,
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: "",
openid_discovery_url: source.openid_discovery_url,
scopes: source.scopes || "openid profile email",
icon_url: source.icon_url,
})
} else {
setForm(emptyForm)
}
} else {
setForm(emptyForm)
setSaving(false)
}
}, [isOpen, source])
const saveSource = async () => {
setSaving(true)
try {
if (source) {
await services.adminAuthSource.updateAuthSource(source.id, form)
} else {
await services.adminAuthSource.createAuthSource(form)
}
await onChanged()
toast.success("认证源已保存")
onClose()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存认证源失败")
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
<DialogDescription>
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<Label>标识符 (英文名称)</Label>
<Input
value={form.name}
disabled={!!source}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如: github"
/>
</div>
<div className="space-y-2">
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
/>
</div>
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>图标 URL (可选)</Label>
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
placeholder="https://... 或留空"
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10">
<div>
<div className="font-medium text-sm">启用认证源</div>
<div className="text-xs text-muted-foreground">启用后会立即显示在登录页和账号绑定中。</div>
</div>
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
</div>
<div className="md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,163 @@
"use client"
import {useMutation, useQuery} from "@tanstack/react-query"
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
import {toast} from "sonner"
import services from "@/lib/services"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Spinner} from "@/components/ui/spinner"
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
</div>
)
}
export function InfoTab() {
const updateQuery = useQuery({
queryKey: ["admin", "update"],
queryFn: () => services.adminStatus.getUpdateStatus(),
refetchInterval: 30 * 60 * 1000,
staleTime: 5 * 60 * 1000,
})
const applyUpdateMutation = useMutation({
mutationFn: () => services.adminStatus.applyUpdate(),
onSuccess: () => {
toast.success("升级包已校验完成,服务正在重启")
},
onError: (error: Error) => {
toast.error(error.message || "应用升级失败")
},
})
const update = updateQuery.data
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-muted p-1.5 text-muted-foreground">
<Sparkles className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">应用更新</CardTitle>
<CardDescription className="text-xs">
检查上游 GitHub Actions Release 并升级当前服务
</CardDescription>
</div>
</div>
{update?.update_available ? (
<Badge>发现新版本</Badge>
) : update ? (
<Badge variant="secondary">已是最新</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-4">
{updateQuery.isLoading ? (
<div className="flex min-h-32 items-center justify-center">
<Spinner />
</div>
) : updateQuery.isError ? (
<div className="flex min-h-32 flex-col items-center justify-center gap-3 text-center">
<p className="text-xs text-muted-foreground">
{updateQuery.error.message || "无法获取上游版本信息"}
</p>
<Button type="button" variant="outline" size="sm" onClick={() => updateQuery.refetch()}>
<RefreshCw data-icon="inline-start" />
重新检查
</Button>
</div>
) : update ? (
<>
<div>
<InfoRow label="当前版本" value={update.current_version} />
<InfoRow label="最新版本" value={update.latest_version} />
{update.build_time && <InfoRow label="构建时间" value={update.build_time} />}
<InfoRow label="运行平台" value={update.platform} />
<InfoRow label="上游仓库" value={update.upstream_repository} />
<InfoRow label="Release 资产" value={update.asset_name} />
</div>
{update.release_notes && (
<div className="flex flex-col gap-2 rounded-md border border-dashed p-3">
<p className="text-xs font-medium">更新说明</p>
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
{update.release_notes}
</p>
</div>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => updateQuery.refetch()} disabled={updateQuery.isFetching}>
{updateQuery.isFetching ? <Spinner data-icon="inline-start" /> : <RefreshCw data-icon="inline-start" />}
检查更新
</Button>
{update.release_url && (
<Button asChild variant="outline" size="sm">
<a href={update.release_url} target="_blank" rel="noreferrer">
<ExternalLink data-icon="inline-start" />
查看 Release
</a>
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" size="sm" disabled={!update.can_upgrade || applyUpdateMutation.isPending}>
{applyUpdateMutation.isPending && <Spinner data-icon="inline-start" />}
立即升级
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
确认升级
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{!update.can_upgrade && (
<p className="text-xs text-muted-foreground">
{update.current_version === "dev"
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
: update.update_available
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
: "当前版本无需升级。"}
</p>
)}
</>
) : null}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,67 @@
"use client"
import Link from "next/link"
import {Bell} from "lucide-react"
import {Switch} from "@/components/ui/switch"
import {Label} from "@/components/ui/label"
import {useNotificationSettings} from "@/contexts/notification-settings-context"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
export function NotificationsMain() {
const { showBell, setShowBell } = useNotificationSettings()
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
设置通知相关的显示选项
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<Bell className="size-5 text-primary" />
<div className="space-y-0.5">
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
显示通知铃铛
</Label>
<p className="text-xs text-muted-foreground">
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,168 @@
"use client"
import {useMemo} from "react"
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
import {KeyRound, ShieldAlert, X} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Badge} from "@/components/ui/badge"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import services from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
import {TemplatesManager} from "./templates"
import {toast} from "sonner"
interface OperationTabProps {
configs: Record<string, SystemConfig>
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
}
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
const queryClient = useQueryClient()
const uploadTypesQuery = useQuery({
queryKey: ["admin", "upload-types"],
queryFn: () => services.adminSystemConfig.listUploadTypes(),
})
const updateWhitelistMutation = useMutation({
mutationFn: async (newValue: string) => {
const config = configs["file_access_whitelist"]
if (!config) {
throw new Error("缺少配置项: file_access_whitelist")
}
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
value: newValue,
description: config.description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("文件访问白名单已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新白名单失败")
},
})
const whitelistConfig = configs["file_access_whitelist"]
const currentWhitelist = useMemo<string[]>(() => {
if (!whitelistConfig?.value) return ["avatar"]
try {
const parsed = JSON.parse(whitelistConfig.value)
if (Array.isArray(parsed)) return parsed
} catch {
// 降级支持逗号分隔解析
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
}
return ["avatar"]
}, [whitelistConfig?.value])
const handleAddType = (type: string) => {
if (!type || currentWhitelist.includes(type)) return
const newWhitelist = [...currentWhitelist, type]
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
}
const handleRemoveType = (typeToRemove: string) => {
const newWhitelist = currentWhitelist.filter(t => t !== typeToRemove)
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
}
const availableTypes = useMemo(() => {
const types = uploadTypesQuery.data ?? []
return types.map(t => {
let label = t
if (t === "avatar") label = "头像 (avatar)"
else if (t === "attachment") label = "附件 (attachment)"
else if (t === "doc") label = "文档 (doc)"
else if (t === "generic") label = "通用 (generic)"
return { value: t, label }
})
}, [uploadTypesQuery.data])
return (
<div className="space-y-6">
{/* 文件访问白名单设置 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<KeyRound className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">添加免鉴权类型:</span>
<Select
value=""
onValueChange={handleAddType}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
>
<SelectTrigger className="w-[200px]" size="sm">
<SelectValue placeholder="选择业务类型..." />
</SelectTrigger>
<SelectContent>
{availableTypes
.filter(t => !currentWhitelist.includes(t.value))
.map(t => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
{availableTypes.filter(t => !currentWhitelist.includes(t.value)).length === 0 && (
<div className="text-xs text-muted-foreground p-2 text-center">所有类型已添加</div>
)}
</SelectContent>
</Select>
</div>
{/* 当前白名单列表 */}
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
<div className="flex items-center gap-2">
<ShieldAlert className="size-4 text-indigo-500" />
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
</div>
{currentWhitelist.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentWhitelist.map(type => (
<Badge
key={type}
variant="secondary"
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
>
{availableTypes.find(t => t.value === type)?.label || type}
<button
type="button"
onClick={() => handleRemoveType(type)}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
白名单已空,所有类型文件的访问都将需要登录。
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* 通知模板管理 */}
<TemplatesManager />
</div>
)
}
@@ -0,0 +1,185 @@
"use client"
import {ComponentType, useMemo} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import {
Bell,
Code,
CreditCard,
Database,
FileText,
FolderOpen,
Home,
Info,
Layers,
LayoutList,
Settings,
ShieldCheck,
Terminal,
UserRound
} from "lucide-react"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
import {toast} from "sonner"
interface MenuItem {
path: string
label: string
description: string
icon: ComponentType<{ className?: string }>
readOnly?: boolean
}
interface MenuGroup {
name: string
items: MenuItem[]
}
const MENU_GROUPS: MenuGroup[] = [
{
name: "基础菜单",
items: [
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
{ path: "/files", label: "我的文件", description: "用户个人文件管理与上传", icon: FolderOpen },
]
},
{
name: "管理菜单",
items: [
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
{ path: "/admin/files", label: "存储管理", description: "管理系统文件存储和清理无用文件", icon: FolderOpen },
{ path: "/admin/database", label: "数据管理", description: "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", icon: Database },
{ path: "/admin/push", label: "通知推送", description: "配置和发送系统通知及推送消息", icon: Bell },
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
]
},
{
name: "文档菜单",
items: [
{ path: "/admin/demo", label: "规范示例", description: "内置 UI 组件与设计规范的展示、调试与参考", icon: Code },
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
]
}
]
interface OtherTabProps {
configs: Record<string, SystemConfig>
}
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient()
const menuDisplayConfig = useMemo(() => {
const raw = configs["menu_display_config"]?.value
if (!raw) return {} as Record<string, boolean>
try {
return JSON.parse(raw) as Record<string, boolean>
} catch {
return {} as Record<string, boolean>
}
}, [configs])
const updateMenuConfigMutation = useMutation({
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
const newConfig = { ...menuDisplayConfig, [path]: enabled }
const currentCfg = configs["menu_display_config"]
await services.adminSystemConfig.updateSystemConfig("menu_display_config", {
value: JSON.stringify(newConfig),
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("目录显示配置已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const handleMenuToggle = (path: string, checked: boolean) => {
updateMenuConfigMutation.mutate({ path, enabled: checked })
}
return (
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<LayoutList className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
<CardDescription className="text-xs">
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{MENU_GROUPS.map((group) => (
<div key={group.name} className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
{group.name}
</span>
<div className="h-px bg-border/40 flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.items.map((item) => {
const Icon = item.icon
const isReadOnly = !!item.readOnly
const checked = menuDisplayConfig[item.path] !== false
return (
<div
key={item.path}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
>
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
{isReadOnly && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
不可隐藏
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
{item.description}
</p>
</div>
<div className="flex items-center">
<Switch
checked={checked}
disabled={isReadOnly || updateMenuConfigMutation.isPending}
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
/>
</div>
</div>
)
})}
</div>
</div>
))}
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">安全提示:</span>
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,749 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {motion, useAnimation} from "motion/react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useUser} from "@/contexts/user-context"
import {
ArrowRight,
BookOpen,
Camera,
Edit,
Globe,
Info,
Link2,
Loader2,
Lock,
Mail,
MapPin,
Phone,
Shield,
Unlink,
User as UserIcon
} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {Textarea} from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Label} from "@/components/ui/label"
import type {ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services/auth"
import {AuthService} from "@/lib/services/auth"
import services from "@/lib/services"
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
import {toast} from "sonner"
export function ProfileMain() {
const { user, loading, refetch } = useUser()
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const queryClient = useQueryClient()
// 修改密码 State
const [oldPassword, setOldPassword] = React.useState("")
const [newPassword, setNewPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
// 编辑个人资料 State
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [nickname, setNickname] = React.useState("")
const [email, setEmail] = React.useState("")
const [bio, setBio] = React.useState("")
const [phone, setPhone] = React.useState("")
const [gender, setGender] = React.useState("secret")
const [website, setWebsite] = React.useState("")
const [location, setLocation] = React.useState("")
const [avatarUrl, setAvatarUrl] = React.useState("")
// 头像裁剪 State
const [cropFile, setCropFile] = React.useState<File | null>(null)
const [isCropDialogOpen, setIsCropDialogOpen] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
// 初始化编辑表单数据
React.useEffect(() => {
if (user) {
setNickname(user.nickname || "")
setEmail(user.email || "")
setBio(user.bio || "")
setPhone(user.phone || "")
setGender(user.gender || "secret")
setWebsite(user.website || "")
setLocation(user.location || "")
setAvatarUrl(user.avatar_url || "")
}
}, [user, isEditDialogOpen])
const changePasswordMutation = useMutation({
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
onSuccess: () => {
toast.success("密码修改成功")
setOldPassword("")
setNewPassword("")
setConfirmPassword("")
void refetch()
},
onError: (error: Error) => {
toast.error(error.message || "修改密码失败,请重试")
},
})
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致")
return
}
if (newPassword.length < 8) {
toast.error("新密码长度不能少于 8 位")
return
}
changePasswordMutation.mutate({
old_password: oldPassword,
new_password: newPassword,
})
}
const updateProfileMutation = useMutation({
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
onSuccess: () => {
toast.success("个人信息修改成功")
setIsEditDialogOpen(false)
void refetch()
},
onError: (error: Error) => {
toast.error(error.message || "修改个人信息失败,请重试")
},
})
const handleProfileSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateProfileMutation.mutate({
nickname,
email,
avatar_url: avatarUrl,
bio,
phone,
gender,
website,
location,
})
}
const externalAccountBindingsQuery = useQuery({
queryKey: ["auth", "external-accounts"],
queryFn: () => AuthService.getExternalAccountBindings(),
})
const publicAuthSourcesQuery = useQuery({
queryKey: ["auth", "public-sources"],
queryFn: () => AuthService.getAuthSources(),
})
const bindSourceMutation = useMutation({
mutationFn: async (sourceName: string) => {
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName, "bind")
sessionStorage.setItem("redirect_after_login", `${window.location.pathname}${window.location.search}`)
window.location.href = authorize_url
},
onError: (error: Error) => {
toast.error(error.message || "绑定认证源失败")
},
})
const handleAvatarClick = () => {
if (isAnimatingRef.current) return
isAnimatingRef.current = true
controls.start({
rotate: [0, -20, 20, -20, 20, 0],
transition: { duration: 0.5, ease: "easeInOut" }
})
setTimeout(() => {
isAnimatingRef.current = false
}, 650)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setCropFile(file)
setIsCropDialogOpen(true)
}
}
const handleCroppedImage = async (croppedBase64: string) => {
try {
const res = await services.upload.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
setAvatarUrl(`/f/${res.id}`)
setIsCropDialogOpen(false)
setCropFile(null)
toast.success("头像上传成功,点击保存以生效")
} catch (err) {
toast.error((err as Error).message || "上传头像失败")
}
}
const getGenderLabel = (g?: string) => {
switch (g) {
case "male": return "男"
case "female": return "女"
case "secret": return "保密"
default: return g || "保密"
}
}
if (loading) {
return (
<div className="py-6 space-y-4 max-w-4xl mx-auto">
<div className="border-b border-border pb-4">
<h1 className="text-2xl font-semibold">个人资料</h1>
</div>
</div>
)
}
if (!user) {
return (
<div className="py-6 space-y-6 max-w-4xl mx-auto">
<div className="text-sm text-muted-foreground">未找到用户信息</div>
</div>
)
}
return (
<div className="py-6 space-y-6 max-w-4xl mx-auto">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
{/* 基本资料面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
</div>
<Button
variant="outline"
size="sm"
className="text-xs border-dashed"
onClick={() => setIsEditDialogOpen(true)}
>
<Edit className="size-3.5 mr-1.5" />
编辑资料
</Button>
</div>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
<motion.div
animate={controls}
onClick={handleAvatarClick}
className="cursor-pointer origin-center shrink-0"
whileHover={{ scale: 1.05 }}
>
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="flex-1 w-full space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
账户
</div>
<div className="text-sm font-semibold">@{user.username}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
昵称
</div>
<div className="text-sm font-semibold">{user.nickname || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Info className="size-3 text-muted-foreground/70" />
用户ID (UID)
</div>
<div className="text-sm font-mono font-semibold">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Mail className="size-3 text-muted-foreground/70" />
邮箱
</div>
<div className="text-sm font-semibold truncate max-w-[220px]">{user.email || '未绑定'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Phone className="size-3 text-muted-foreground/70" />
手机号码
</div>
<div className="text-sm font-semibold">{user.phone || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
性别
</div>
<div className="text-sm font-semibold">{getGenderLabel(user.gender)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Globe className="size-3 text-muted-foreground/70" />
个人网站
</div>
<div className="text-sm font-semibold truncate max-w-[220px]">
{user.website ? (
<a
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
{user.website}
</a>
) : (
'未设置'
)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<MapPin className="size-3 text-muted-foreground/70" />
所在地
</div>
<div className="text-sm font-semibold">{user.location || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Shield className="size-3 text-muted-foreground/70" />
管理员身份
</div>
<div className="text-sm font-semibold flex items-center gap-1">
{user.is_admin ? (
<span className="text-rose-600 flex items-center gap-1">
<Shield className="size-3.5" />
是
</span>
) : (
<span>否</span>
)}
</div>
</div>
</div>
<Separator className="border-dashed" />
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<BookOpen className="size-3 text-muted-foreground/70" />
个人简介
</div>
<div className="text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3">
{user.bio || '这个人很懒,什么都没有留下。'}
</div>
</div>
</div>
</div>
</div>
{/* 下方左右并排布局容器 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 修改密码面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
<div>
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
<Lock className="size-4" />
</div>
<div>
<h2 className="text-base font-semibold tracking-tight">修改密码</h2>
<p className="text-[11px] text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
</div>
</div>
{user.need_change_password && (
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5">
<Info className="size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">密码风险提示</p>
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
为了账号安全,您必须修改初始密码。
</p>
</div>
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-3 pt-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
<Input
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">新密码</label>
<Input
type="password"
placeholder="新密码(至少 8 位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="pt-2">
<Button
type="submit"
size="sm"
className="w-full sm:w-auto h-8 text-xs"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
</Button>
</div>
</form>
</div>
</div>
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
<div>
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Link2 className="size-4" />
</div>
<div>
<h2 className="text-base font-semibold tracking-tight">第三方账号绑定</h2>
<p className="text-[11px] text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
</div>
</div>
{/* 已绑定账号列表 */}
<div className="space-y-2 pt-4">
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
{externalAccountBindingsQuery.isPending ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin text-indigo-500" />
</div>
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
<div className="space-y-2">
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card hover:bg-muted/10 transition-all duration-300"
>
<div className="space-y-0.5">
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
<span className="text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]">
{binding.external_username || binding.email || "未提供账号标识"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-[11px] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-7 px-2 transition-colors"
onClick={async () => {
await AuthService.deleteExternalAccountBinding(binding.id)
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
toast.success("绑定已移除")
}}
>
<Unlink className="size-3 mr-1" />
解绑
</Button>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
<Link2 className="size-5 text-muted-foreground/30 mb-1" />
暂无绑定的第三方账号
</div>
)}
</div>
<Separator className="border-dashed my-4" />
{/* 绑定新账号列表 */}
<div className="space-y-2">
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
{publicAuthSourcesQuery.isPending ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin text-indigo-500" />
</div>
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{(publicAuthSourcesQuery.data ?? []).map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
onClick={() => {
void bindSourceMutation.mutateAsync(source.name)
}}
>
<div className="flex items-center gap-1.5">
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
<span>绑定 {source.display_name || source.name}</span>
</div>
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
</Button>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground text-center py-2">
暂无可用第三方认证源
</div>
)}
</div>
</div>
</div>
</div>
{/* 编辑资料 Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>修改您的基本信息,保存后立即生效。</DialogDescription>
</DialogHeader>
<form onSubmit={handleProfileSubmit} className="space-y-4">
{/* 上传头像域 */}
<div className="flex flex-col items-center gap-2 py-2">
<div
className="group relative cursor-pointer rounded-full border border-dashed hover:border-primary/50 transition-all"
onClick={() => fileInputRef.current?.click()}
>
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
<AvatarImage src={avatarUrl} alt={nickname} />
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
{(nickname || "U").slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="size-5 text-white" />
</div>
</div>
<span className="text-[10px] text-muted-foreground">点击上传/修改头像</span>
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="nickname" className="text-xs">昵称</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入您的昵称"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="email" className="text-xs">邮箱</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入您的邮箱"
/>
</div>
<div className="space-y-1">
<Label htmlFor="phone" className="text-xs">手机号码</Label>
<Input
id="phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入手机号"
/>
</div>
<div className="space-y-1">
<Label htmlFor="gender" className="text-xs">性别</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger id="gender" size="sm" className="w-full text-xs h-8">
<SelectValue placeholder="选择性别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">男</SelectItem>
<SelectItem value="female">女</SelectItem>
<SelectItem value="secret">保密</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="website" className="text-xs">个人网站</Label>
<Input
id="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="https://..."
/>
</div>
<div className="space-y-1">
<Label htmlFor="location" className="text-xs">所在地</Label>
<Input
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="如:北京"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="bio" className="text-xs">个人简介</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
className="text-xs min-h-[60px] rounded-lg"
placeholder="介绍一下自己吧..."
/>
</div>
<DialogFooter className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => setIsEditDialogOpen(false)}
>
取消
</Button>
<Button
type="submit"
size="sm"
className="h-8 text-xs"
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? "保存中..." : "保存修改"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 头像裁剪 Dialog */}
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>裁剪头像</DialogTitle>
<DialogDescription>调整头像位置和大小,使其处于居中圆形区域内。</DialogDescription>
</DialogHeader>
{cropFile && (
<div className="flex flex-col items-center gap-4 py-2">
<ImageCrop file={cropFile} aspect={1} onCrop={handleCroppedImage}>
<ImageCropContent className="max-w-sm rounded-lg border border-dashed" />
<div className="mt-3 flex justify-center gap-2">
<ImageCropReset />
<ImageCropApply />
</div>
</ImageCrop>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
setIsCropDialogOpen(false)
setCropFile(null)
}}
>
取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,594 @@
"use client"
import {useEffect, useState} from "react"
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
import {
Clock,
Fingerprint,
Globe,
Loader2,
Lock,
Mail,
Pencil,
Plus,
Settings,
Shield,
Trash2,
UserPlus
} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Switch} from "@/components/ui/switch"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
import services from "@/lib/services"
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
import {toast} from "sonner"
const SECURITY_KEYS = [
{
key: "password_login_enabled",
title: "允许密码登录",
description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。",
icon: Lock,
},
{
key: "registration_enabled",
title: "允许注册",
description: "关闭后系统将禁止新用户进行自主账号注册。",
icon: UserPlus,
},
{
key: "password_register_enabled",
title: "允许密码注册",
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
icon: Fingerprint,
},
{
key: "oidc_login_enabled",
title: "允许 OIDC 登录",
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
icon: Globe,
},
{
key: "email_login_verification_enabled",
title: "邮箱登录验证",
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
icon: Mail,
},
{
key: "email_register_verification_enabled",
title: "邮箱注册验证",
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
icon: Mail,
},
] as const
interface SecurityTabProps {
configs: Record<string, SystemConfig>
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
}
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient()
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
const [capCount, setCapCount] = useState("")
const [capDifficulty, setCapDifficulty] = useState("")
const [capSize, setCapSize] = useState("")
const [capTTL, setCapTTL] = useState("")
const [capTokenTTL, setCapTokenTTL] = useState("")
const [capAutoSolve, setCapAutoSolve] = useState(true)
const [sessionTTL, setSessionTTL] = useState("168")
const [customHours, setCustomHours] = useState("")
const authSourcesQuery = useQuery({
queryKey: ["auth", "sources"],
queryFn: () => services.adminAuthSource.listAuthSources(),
})
useEffect(() => {
if (systemConfigsQuery.data) {
const cfgMap = configs
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
// 初始化登录保持设置
const ttlVal = cfgMap["login_session_ttl_hours"]?.value || "0"
if (ttlVal === "0" || ttlVal === "168" || ttlVal === "720" || ttlVal === "-1") {
setSessionTTL(ttlVal)
setCustomHours("")
} else {
setSessionTTL("custom")
setCustomHours(ttlVal)
}
}
}, [systemConfigsQuery.data, configs])
const updateTTLMutation = useMutation({
mutationFn: async (value: string) => {
const config = configs["login_session_ttl_hours"]
if (!config) {
throw new Error("缺少配置项: login_session_ttl_hours")
}
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
value: value,
description: config.description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("登录状态保持时间已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const handleTTLChange = (val: string) => {
setSessionTTL(val)
if (val !== "custom") {
updateTTLMutation.mutate(val)
}
}
const handleCustomBlur = () => {
const parsed = parseInt(customHours, 10)
if (isNaN(parsed) || parsed <= 0) {
toast.error("请输入有效的过期小时数(正整数)")
// 重置为原本的值
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
return
}
updateTTLMutation.mutate(parsed.toString())
}
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key]
if (!config) {
throw new Error(`缺少配置项: ${key}`)
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
description: config.description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("系统安全配置已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const toggleSourceMutation = useMutation({
mutationFn: async (source: AuthSource) => {
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源状态已更新")
},
onError: (error: Error) => {
toast.error(error.message || "切换状态失败")
},
})
const deleteSourceMutation = useMutation({
mutationFn: async (sourceId: string) => {
await services.adminAuthSource.deleteAuthSource(sourceId)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源已删除")
},
onError: (error: Error) => {
toast.error(error.message || "删除认证源失败")
},
})
const handleToggle = (key: string, checked: boolean) => {
updateConfigMutation.mutate({ key, value: checked })
}
const saveCapMutation = useMutation({
mutationFn: async () => {
const updates = [
{ key: "cap_challenge_count", value: capCount },
{ key: "cap_challenge_difficulty", value: capDifficulty },
{ key: "cap_challenge_size", value: capSize },
{ key: "cap_challenge_ttl_seconds", value: capTTL },
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
]
for (const update of updates) {
const currentCfg = configs[update.key]
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("人机验证配置已成功保存")
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
},
})
const handleCapSave = (e: React.FormEvent) => {
e.preventDefault()
saveCapMutation.mutate()
}
return (
<div className="space-y-6">
{/* 系统登录与注册控制 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Settings className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SECURITY_KEYS.map((item) => {
const config = configs[item.key]
const checked = config ? config.value === "true" : false
const Icon = item.icon
return (
<div
key={item.key}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-indigo-500" />}
<span className="font-medium text-sm text-foreground">{item.title}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
</div>
<Switch
checked={checked}
disabled={updateConfigMutation.isPending}
onCheckedChange={(value) => handleToggle(item.key, value)}
/>
</div>
)
})}
{/* 登录状态保持时间 (选择后立即更改) */}
<div
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm md:col-span-2"
>
<div className="space-y-1 pr-4">
<div className="flex items-center gap-2">
<Clock className="size-4 text-indigo-500" />
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Select
value={sessionTTL}
disabled={updateTTLMutation.isPending}
onValueChange={handleTTLChange}
>
<SelectTrigger className="w-[180px] bg-card border-dashed text-xs h-8">
<SelectValue placeholder="选择保留时间" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">关闭 (浏览器关闭自动退登)</SelectItem>
<SelectItem value="168">7 天</SelectItem>
<SelectItem value="720">30 天</SelectItem>
<SelectItem value="-1">永不过期</SelectItem>
<SelectItem value="custom">自定义时长</SelectItem>
</SelectContent>
</Select>
{sessionTTL === "custom" && (
<Input
type="number"
min={1}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCustomBlur()
}
}}
placeholder="小时"
disabled={updateTTLMutation.isPending}
className="w-20 bg-card border-dashed text-xs h-8 px-2"
/>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 认证源配置管理 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Globe className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
}}
variant="secondary"
>
<Plus className="mr-1.5 size-3.5" />
新增认证源
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-3">
{authSourcesQuery.isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
</div>
) : (authSourcesQuery.data ?? []).length > 0 ? (
(authSourcesQuery.data ?? []).map((source) => (
<div
key={source.id}
className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">{source.display_name || source.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
source.is_active
? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
}`}>
{source.is_active ? "已启用" : "已禁用"}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
标识符: {source.name} · 类型: {source.type.toUpperCase()}
</div>
</div>
<div className="flex items-center gap-4">
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
source.client_secret_configured
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
}`}>
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
</span>
<div className="flex items-center gap-2">
<Switch
checked={source.is_active}
disabled={toggleSourceMutation.isPending}
className="scale-90 mr-2"
onCheckedChange={() => toggleSourceMutation.mutate(source)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
onClick={() => {
setSelectedSource(source)
setAuthSourceModalOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
disabled={deleteSourceMutation.isPending}
onClick={() => {
if (window.confirm(`确定删除认证源「${source.display_name || source.name}」吗?`)) {
deleteSourceMutation.mutate(source.id)
}
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
<span>暂无配置的认证源,点击上方按钮新增</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
}}
className="border-dashed"
>
<Plus className="mr-1.5 size-3.5" />
新增认证源
</Button>
</div>
)}
</CardContent>
</Card>
{/* 人机验证配置 (Cap CAPTCHA) */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Shield className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
</div>
</div>
<Switch
checked={configs["cap_login_enabled"]?.value === "true"}
disabled={updateConfigMutation.isPending}
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
/>
</CardHeader>
<CardContent className="pt-6">
{/* 自动开始计算 Switch */}
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
<div className="space-y-0.5">
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
</div>
<Switch
checked={capAutoSolve}
onCheckedChange={setCapAutoSolve}
/>
</div>
<form onSubmit={handleCapSave} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
<Input
id="cap_challenge_count"
type="number"
min={1}
max={100}
value={capCount}
onChange={(e) => setCapCount(e.target.value)}
placeholder="50"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
<Input
id="cap_challenge_difficulty"
type="number"
min={1}
max={10}
value={capDifficulty}
onChange={(e) => setCapDifficulty(e.target.value)}
placeholder="4"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
<Input
id="cap_challenge_size"
type="number"
min={8}
max={64}
value={capSize}
onChange={(e) => setCapSize(e.target.value)}
placeholder="32"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
<Input
id="cap_challenge_ttl"
type="number"
min={10}
value={capTTL}
onChange={(e) => setCapTTL(e.target.value)}
placeholder="600"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
<Input
id="cap_token_ttl"
type="number"
min={10}
value={capTokenTTL}
onChange={(e) => setCapTokenTTL(e.target.value)}
placeholder="1200"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-dashed">
<Button
type="submit"
size="sm"
disabled={saveCapMutation.isPending}
>
{saveCapMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
保存中...
</>
) : (
"保存配置"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<AuthSourceModal
isOpen={authSourceModalOpen}
source={selectedSource}
onClose={() => setAuthSourceModalOpen(false)}
onChanged={async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
await authSourcesQuery.refetch()
}}
/>
</div>
)
}
@@ -0,0 +1,484 @@
"use client"
import {useEffect, useState} from "react"
import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
import {Globe, Loader2, Mail, Search, Server, Sparkles} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Badge} from "@/components/ui/badge"
import services from "@/lib/services"
import type {SystemConfig} from "@/lib/services/admin"
import {toast} from "sonner"
interface SystemTabProps {
configs: Record<string, SystemConfig>
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
}
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const queryClient = useQueryClient()
const [serverAddress, setServerAddress] = useState("")
const [siteName, setSiteName] = useState("")
const [smtpHost, setSmtpHost] = useState("")
const [smtpPort, setSmtpPort] = useState("")
const [smtpUsername, setSmtpUsername] = useState("")
const [smtpPassword, setSmtpPassword] = useState("")
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
const [smtpTestTo, setSmtpTestTo] = useState("")
const [smtpTestLog, setSmtpTestLog] = useState("")
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
const [smtpTestError, setSmtpTestError] = useState("")
useEffect(() => {
if (systemConfigsQuery.data) {
setServerAddress(configs["server_address"]?.value || "")
setSiteName(configs["site_name"]?.value || "")
setSmtpHost(configs["smtp_host"]?.value || "")
setSmtpPort(configs["smtp_port"]?.value || "587")
setSmtpUsername(configs["smtp_username"]?.value || "")
setSmtpPassword(configs["smtp_password"]?.value || "")
}
}, [systemConfigsQuery.data, configs])
const handleDetectAddress = () => {
if (typeof window !== "undefined") {
setServerAddress(window.location.origin)
toast.success("已自动获取当前域名并填充")
}
}
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key]
if (!config) {
throw new Error(`缺少配置项: ${key}`)
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
description: config.description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("配置已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const saveSystemMutation = useMutation({
mutationFn: async () => {
const currentAddrCfg = configs["server_address"]
const currentSiteCfg = configs["site_name"]
await Promise.all([
services.adminSystemConfig.updateSystemConfig("server_address", {
value: serverAddress,
description: currentAddrCfg?.description || "服务器地址",
}),
services.adminSystemConfig.updateSystemConfig("site_name", {
value: siteName,
description: currentSiteCfg?.description || "系统平台的展示名称",
}),
])
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
])
toast.success("通用配置已成功保存")
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
},
})
const handleSystemSave = (e: React.FormEvent) => {
e.preventDefault()
saveSystemMutation.mutate()
}
const saveSmtpMutation = useMutation({
mutationFn: async () => {
const updates = [
{ key: "smtp_host", value: smtpHost },
{ key: "smtp_port", value: smtpPort },
{ key: "smtp_username", value: smtpUsername },
{ key: "smtp_password", value: smtpPassword },
]
for (const update of updates) {
const currentCfg = configs[update.key]
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
// If already configured and sent empty or mask, skip updating it (keep existing)
if (currentCfg && currentCfg.value === "******") {
continue
}
}
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("SMTP 邮件配置已成功保存")
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
},
})
const handleSmtpSave = (e: React.FormEvent) => {
e.preventDefault()
saveSmtpMutation.mutate()
}
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog("正在发起连接测试...\n")
setSmtpTestSuccess(null)
setSmtpTestError("")
const res = await services.adminSystemConfig.testSMTP({
smtp_host: smtpHost,
smtp_port: parseInt(smtpPort, 10) || 587,
smtp_username: smtpUsername,
smtp_password: smtpPassword,
to: smtpTestTo,
})
return res
},
onSuccess: (data) => {
setSmtpTestLog(data.log)
if (data.success) {
setSmtpTestSuccess(true)
toast.success("测试邮件发送成功")
} else {
setSmtpTestSuccess(false)
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
toast.error("测试邮件发送失败")
}
},
onError: (error: Error) => {
setSmtpTestSuccess(false)
setSmtpTestError(error.message || "请求发送失败")
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
toast.error(error.message || "测试请求发送失败")
},
})
const handleSmtpTestSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!smtpTestTo) {
toast.error("请输入目标邮箱地址")
return
}
testSmtpMutation.mutate()
}
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
return (
<div className="space-y-8">
{/* 通用设置 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
<Server className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
<CardDescription className="text-xs">
配置站点标识、服务地址与搜索引擎收录策略
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<form onSubmit={handleSystemSave} className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="site_name" className="text-xs font-semibold">
站点名称
</Label>
<Input
id="site_name"
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="例如: Wavelet"
className="border-dashed bg-card text-xs"
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
用于网页标题、登录注册页面、管理后台与系统邮件。
</p>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="server_address" className="text-xs font-semibold">
服务器访问地址
</Label>
<Button
type="button"
variant="link"
size="sm"
onClick={handleDetectAddress}
className="h-auto px-0 text-xs"
>
<Sparkles data-icon="inline-start" />
使用当前域名
</Button>
</div>
<Input
id="server_address"
type="url"
value={serverAddress}
onChange={(e) => setServerAddress(e.target.value)}
placeholder="例如: https://example.com"
className="border-dashed bg-card text-xs"
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
限定 API 的允许来源;留空将开放任意来源访问。
</p>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-md bg-muted p-1.5 text-muted-foreground">
<Search className="size-4" />
</div>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold">搜索引擎抓取与收录</span>
<Badge variant={indexingEnabled ? "default" : "secondary"}>
{indexingEnabled ? "允许收录" : "禁止收录"}
</Badge>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
</p>
</div>
</div>
<Switch
aria-label="允许搜索引擎抓取与收录"
checked={indexingEnabled}
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
onCheckedChange={(checked) =>
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked })
}
/>
</div>
<div className="flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Globe className="size-3.5" />
地址变更会影响跨域访问策略,请填写完整协议与域名。
</div>
<Button
type="submit"
size="sm"
disabled={saveSystemMutation.isPending}
>
{saveSystemMutation.isPending ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
保存中...
</>
) : (
"保存配置"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* SMTP 邮件设置 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Mail className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<form onSubmit={handleSmtpSave} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
<Input
id="smtp_host"
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="例如: smtp.example.com"
className="bg-card border-dashed text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
<Input
id="smtp_port"
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="例如: 587 或 465"
className="bg-card border-dashed text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
<Input
id="smtp_username"
type="text"
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
placeholder="例如: sender@example.com"
className="bg-card border-dashed text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
<Input
id="smtp_password"
type="password"
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
className="bg-card border-dashed text-xs"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSmtpTestOpen(true)
setSmtpTestTo("")
setSmtpTestLog("")
setSmtpTestSuccess(null)
setSmtpTestError("")
}}
disabled={saveSmtpMutation.isPending}
>
测试发件
</Button>
<Button
type="submit"
size="sm"
disabled={saveSmtpMutation.isPending}
>
{saveSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
保存中...
</>
) : (
"保存配置"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
<DialogContent className="max-w-lg border border-dashed">
<DialogHeader>
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
<DialogDescription className="text-xs">
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSmtpTestSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="smtp_test_to" className="text-xs font-semibold">目标邮箱地址</Label>
<Input
id="smtp_test_to"
type="email"
required
value={smtpTestTo}
onChange={(e) => setSmtpTestTo(e.target.value)}
placeholder="例如: receiver@example.com"
className="bg-card border-dashed text-xs"
disabled={testSmtpMutation.isPending}
/>
</div>
{smtpTestLog && (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">连接与传输日志</Label>
<pre className="bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed">
{smtpTestLog}
</pre>
</div>
)}
{smtpTestSuccess === true && (
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
测试成功!邮件已顺利发出。
</div>
)}
{smtpTestSuccess === false && (
<div className="p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all">
测试失败:{smtpTestError}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
关闭
</Button>
<Button
type="submit"
size="sm"
disabled={testSmtpMutation.isPending}
>
{testSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
测试中...
</>
) : (
"开始测试"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,374 @@
"use client"
import {useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import services from "@/lib/services"
import type {Template} from "@/lib/services/admin/types"
import {toast} from "sonner"
export function TemplatesManager() {
const queryClient = useQueryClient()
const [modalOpen, setModalOpen] = useState(false)
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
// Form states
const [key, setKey] = useState("")
const [name, setName] = useState("")
const [type, setType] = useState("email")
const [subject, setSubject] = useState("")
const [content, setContent] = useState("")
const [description, setDescription] = useState("")
const templatesQuery = useQuery({
queryKey: ["admin", "templates"],
queryFn: () => services.adminTemplate.listTemplates(),
})
const createTemplateMutation = useMutation({
mutationFn: async () => {
await services.adminTemplate.createTemplate({
key,
name,
type,
subject,
content,
description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功创建")
setModalOpen(false)
},
onError: (error: Error) => {
toast.error(error.message || "创建模板失败")
},
})
const updateTemplateMutation = useMutation({
mutationFn: async (key: string) => {
await services.adminTemplate.updateTemplate(key, {
name,
type,
subject,
content,
description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功保存")
setModalOpen(false)
},
onError: (error: Error) => {
toast.error(error.message || "修改模板失败")
},
})
const deleteTemplateMutation = useMutation({
mutationFn: async (key: string) => {
await services.adminTemplate.deleteTemplate(key)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已删除")
},
onError: (error: Error) => {
toast.error(error.message || "删除模板失败")
},
})
const handleOpenCreate = () => {
setSelectedTemplate(null)
setKey("")
setName("")
setType("email")
setSubject("")
setContent("")
setDescription("")
setModalOpen(true)
}
const handleOpenEdit = (tmpl: Template) => {
setSelectedTemplate(tmpl)
setKey(tmpl.key)
setName(tmpl.name)
setType(tmpl.type)
setSubject(tmpl.subject || "")
setContent(tmpl.content)
setDescription(tmpl.description || "")
setModalOpen(true)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (selectedTemplate) {
updateTemplateMutation.mutate(selectedTemplate.key)
} else {
createTemplateMutation.mutate()
}
}
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
return (
<div className="space-y-6">
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<FileText className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
<CardDescription className="text-xs">
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
onClick={handleOpenCreate}
variant="secondary"
>
<Plus className="mr-1.5 size-3.5" />
新增模板
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{templatesQuery.isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
</div>
) : (templatesQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{(templatesQuery.data ?? []).map((tmpl) => (
<div
key={tmpl.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
>
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
tmpl.is_system
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
}`}>
{tmpl.is_system ? "系统内置" : "自定义"}
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono">
{tmpl.type.toUpperCase()}
</span>
</div>
<div className="text-xs text-muted-foreground">
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
</div>
{tmpl.description && (
<p className="text-xs text-muted-foreground/80 leading-relaxed max-w-xl">
{tmpl.description}
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
onClick={() => handleOpenEdit(tmpl)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
disabled={tmpl.is_system || deleteTemplateMutation.isPending}
onClick={() => {
if (window.confirm(`确定删除模板「${tmpl.name}」吗?`)) {
deleteTemplateMutation.mutate(tmpl.key)
}
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
<span>暂无配置的通知模板,点击上方按钮新增</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleOpenCreate}
className="border-dashed"
>
<Plus className="mr-1.5 size-3.5" />
新增模板
</Button>
</div>
)}
</CardContent>
</Card>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-xl border border-dashed">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
</DialogTitle>
<DialogDescription className="text-xs">
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="tmpl_key" className="text-xs font-semibold">
模板标识符 (Key)
</Label>
<Input
id="tmpl_key"
type="text"
required
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="例如: login_email"
className="bg-card border-dashed text-xs"
disabled={!!selectedTemplate}
/>
<p className="text-[10px] text-muted-foreground leading-normal">
模板的唯一标识,在代码中通过此 Key 调用。
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="tmpl_name" className="text-xs font-semibold">
模板名称
</Label>
<Input
id="tmpl_name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如: 登录验证码邮件"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">
用于后台识别该模板的可读名称。
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="tmpl_type" className="text-xs font-semibold">
模板类型
</Label>
<Input
id="tmpl_type"
type="text"
required
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="email"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">
模板分类标识,目前支持 `email`。
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="tmpl_subject" className="text-xs font-semibold">
模板主题 (Subject)
</Label>
<Input
id="tmpl_subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="例如: Wavelet 登录验证码"
className="bg-card border-dashed text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-normal">
邮件标题(类型为 email 时生效)。
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="tmpl_description" className="text-xs font-semibold">
模板说明与变量描述
</Label>
<Input
id="tmpl_description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
className="bg-card border-dashed text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tmpl_content" className="text-xs font-semibold">
模板正文内容 (Content)
</Label>
<Textarea
id="tmpl_content"
required
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
rows={8}
className="bg-card border-dashed text-xs font-mono"
/>
</div>
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setModalOpen(false)}
disabled={isPending}
>
取消
</Button>
<Button
type="submit"
size="sm"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
保存中...
</>
) : (
"保存配置"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,366 @@
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {
ChevronLeft,
ChevronRight,
Download,
FileArchive,
FileAudio,
FileImage,
FileText,
FileVideo,
Globe,
Loader2,
Lock,
Plus,
Search,
Trash2,
Upload
} from "lucide-react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Card, CardContent} from "@/components/ui/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {FileImagePreview} from "@/components/common/file-image-preview"
import services, {formatFileSize} from "@/lib/services"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-10") {
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
export function UserFileManager() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [page, setPage] = React.useState(1)
const [uploading, setUploading] = React.useState(false)
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0) // 默认私有
const fileInputRef = React.useRef<HTMLInputElement>(null)
const pageSize = 12 // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
// 我的文件列表查询
const { data, isPending } = useQuery({
queryKey: ["user-files", page, pageSize, debouncedKeyword],
queryFn: () => services.upload.listMyUploads(page, pageSize, debouncedKeyword || undefined),
})
const files = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
// 上传文件 Mutation
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
return services.upload.uploadFile(file, "generic", undefined, uploadAccessMode)
},
onMutate: () => {
setUploading(true)
},
onSuccess: () => {
toast.success("文件上传成功")
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
},
onError: (err: Error) => {
toast.error(err.message || "上传失败")
},
onSettled: () => {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
})
// 删除文件 Mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => services.upload.deleteMyFile(id),
onSuccess: () => {
toast.success("文件已成功删除")
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
setDeleteTarget(null)
},
onError: (err: Error) => {
toast.error(err.message || "删除失败")
}
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
uploadMutation.mutate(file)
}
}
const triggerUploadClick = () => {
fileInputRef.current?.click()
}
const handleDownload = (file: UploadRecord) => {
const url = services.upload.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
return (
<div className="flex w-full flex-col gap-6">
{/* 操作栏:搜索、权限选择、上传按钮 */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9"
/>
</div>
<div className="flex items-center gap-3 self-end sm:self-auto">
{/* 上传权限设置 */}
<div className="flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs">
<Button
variant={uploadAccessMode === 0 ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs font-normal"
onClick={() => setUploadAccessMode(0)}
>
<Lock className="mr-1 size-3 text-amber-500" />
私有
</Button>
<Button
variant={uploadAccessMode === 1 ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs font-normal"
onClick={() => setUploadAccessMode(1)}
>
<Globe className="mr-1 size-3 text-sky-500" />
公开
</Button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
disabled={uploading}
/>
<Button
onClick={triggerUploadClick}
disabled={uploading}
className="h-9 gap-1.5 text-xs font-medium"
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Plus className="size-3.5" />
)}
{uploading ? "正在上传..." : "上传文件"}
</Button>
</div>
</div>
{/* 文件展示区 */}
{isPending ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
) : files.length === 0 ? (
<Card className="border border-dashed py-16 flex flex-col items-center justify-center text-center">
<CardContent className="flex flex-col items-center gap-4">
<div className="p-4 bg-muted rounded-full text-muted-foreground/60">
<Upload className="size-10" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-sm">还没有上传文件</h3>
<p className="text-xs text-muted-foreground max-w-xs">
{debouncedKeyword ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"}
</p>
</div>
{!debouncedKeyword && (
<Button onClick={triggerUploadClick} disabled={uploading} size="sm" className="mt-2 text-xs">
选择文件上传
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{files.map((file) => (
<Card
key={file.id}
className="group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full"
>
{/* 文件预览/图标区 */}
<div className="h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors">
{file.mime_type.startsWith("image/") ? (
<FileImagePreview
fileId={file.id}
alt={file.file_name}
quality="medium"
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
fallbackClassName="min-h-full w-full rounded-md"
/>
) : (
getFileIcon(file.mime_type)
)}
{/* 访问权限 Badge */}
<div className="absolute top-2 right-2">
{file.access_mode === 0 ? (
<Badge variant="secondary" className="bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal">
<Lock className="size-2.5" />
私有
</Badge>
) : (
<Badge variant="secondary" className="bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal">
<Globe className="size-2.5" />
公开
</Badge>
)}
</div>
</div>
{/* 文件详情区 */}
<CardContent className="p-4 flex-1 flex flex-col justify-between gap-3">
<div className="space-y-1">
<h4 className="font-semibold text-xs text-foreground truncate select-all" title={file.file_name}>
{file.file_name}
</h4>
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-mono">
<span>{formatFileSize(file.file_size)}</span>
<span className="truncate max-w-[120px]" title={file.mime_type}>{file.mime_type}</span>
</div>
</div>
<div className="flex items-center justify-between pt-1 border-t border-dashed">
<span className="text-[9px] text-muted-foreground/80">{formatDate(file.created_at)}</span>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="下载文件"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除文件"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="mr-1 size-3.5" />
上一页
</Button>
<span className="text-xs text-muted-foreground font-medium px-2">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
<ChevronRight className="ml-1 size-3.5" />
</Button>
</div>
)}
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent suppressHydrationWarning>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?删除后此文件将无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,142 @@
import * as React from "react";
import Link from "next/link";
import {cn} from "@/lib/utils";
import {motion} from "motion/react";
import {Button} from "@/components/ui/button";
import {Book, Check, Copy, Terminal} from "lucide-react";
export interface DeveloperSectionProps {
className?: string;
}
/**
* Developer Section - 开发者体验展示
*/
export const DeveloperSection = React.memo(function DeveloperSection({ className }: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const codeContent = `# 注册用户
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
"username": "developer",
"email": "dev@example.com",
"password": "secure_password"
}'`;
const onCopy = async () => {
try {
await navigator.clipboard.writeText(codeContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = codeContent;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<section className={cn("relative z-10 w-full min-h-screen flex items-center justify-center px-6 overflow-hidden", className)}>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 [mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] max-w-[90vw] max-h-[90vh] bg-purple-500/10 rounded-full blur-[120px] animate-pulse" />
</div>
</div>
<div className="container mx-auto max-w-7xl grid lg:grid-cols-2 gap-12 lg:gap-20 items-center relative z-10">
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="order-2 lg:order-1 relative"
>
<div className="relative overflow-hidden rounded-xl border border-white/20 bg-black backdrop-blur-xl shadow-2xl max-w-full">
<div className="flex items-center px-4 py-3 border-b border-white/20 w-full">
<div className="flex-1 flex gap-2">
<div className="w-3 h-3 rounded-full bg-[#ff5f56]" />
<div className="w-3 h-3 rounded-full bg-[#ffbd2e]" />
<div className="w-3 h-3 rounded-full bg-[#27c93f]" />
</div>
<div className="flex-none text-xs text-muted-foreground font-mono flex items-center gap-1">
<Terminal className="w-3 h-3" />
bash
</div>
<div className="flex-1" />
</div>
<div className="p-6 overflow-x-auto relative group">
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 text-neutral-400 hover:text-white" onClick={onCopy}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<pre className="text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto">
<code className="block">
<span className="text-green-400"># 快速开始</span>{'\n'}
<span className="text-purple-400">curl</span> -X <span className="text-yellow-400">POST</span> <span className="text-green-400">https://api.example.com/api/v1/auth/register</span> \{'\n'}
{' '}-H <span className="text-blue-400">&quot;Content-Type: application/json&quot;</span> \{'\n'}
{' '}-d <span className="text-orange-400">&quot;username=dev&amp;password=***&quot;</span>
</code>
</pre>
</div>
</div>
<div className="absolute -z-10 -bottom-10 -right-10 w-40 h-40 bg-blue-500/20 rounded-full blur-3xl animate-pulse" />
</motion.div>
<div className="flex flex-col justify-center space-y-8 order-1 lg:order-2">
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6">
开发者友好,<br />
极速集成上手
</h2>
<p className="text-muted-foreground text-lg leading-relaxed mb-8">
标准化的 RESTful API 接口,完整的 TypeScript 类型定义,详细的文档和 Swagger UI。无论使用什么编程语言,只需几行代码,即可快速集成。
</p>
<ul className="space-y-4 mb-8">
{[
"RESTful API,语义清晰,易于理解",
"OpenAPI / Swagger 接口文档",
"TypeScript 类型定义,开发友好",
"丰富的代码示例和集成指南"
].map((item, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-foreground/80">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Check className="w-3.5 h-3.5" />
</div>
{item}
</li>
))}
</ul>
<div className="flex flex-wrap gap-4">
<Link href="/docs/api">
<Button variant="secondary" className="rounded-full text-xs hover:bg-muted-foreground/10">
<Book className="w-3 h-3" />
API 文档
</Button>
</Link>
</div>
</motion.div>
</div>
</div>
</section>
);
});

Some files were not shown because too many files have changed in this diff Show More