mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
prettier
This commit is contained in:
@@ -22,11 +22,13 @@ Modern frontend application for the Wavelet scaffold framework.
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Run the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -22,11 +22,13 @@ Wavelet 脚手架系统的现代化前端应用。
|
||||
### 安装
|
||||
|
||||
1. 安装依赖:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. 运行开发服务器:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {LoginPage} from "@/components/auth/login-page"
|
||||
import { Suspense } from 'react';
|
||||
import { LoginPage } from '@/components/auth/login-page';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {RegisterPage} from "@/components/auth/register-page"
|
||||
import { Suspense } from 'react';
|
||||
import { RegisterPage } from '@/components/auth/register-page';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterPage />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
|
||||
import {apiSections, DOCS_LAST_UPDATED} from "@/components/common/docs/api";
|
||||
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 接口文档"
|
||||
title='API 接口文档'
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={apiSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
OpenFlare 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
OpenFlare 提供简单、强大的 API
|
||||
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
|
||||
接口,您可以轻松地与后端服务进行集成与二次开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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";
|
||||
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="平台使用指南"
|
||||
title='平台使用指南'
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={howToUseSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
欢迎使用 OpenFlare 管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
欢迎使用 OpenFlare
|
||||
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,20 +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"
|
||||
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)"
|
||||
title='隐私政策 (Privacy)'
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={privacySections}
|
||||
description={
|
||||
<span>
|
||||
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
|
||||
<br className="hidden md:block" />
|
||||
<br className='hidden md:block' />
|
||||
以及您享有的相关权利。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"
|
||||
import {TERMS_LAST_UPDATED, termsSections} from "@/components/common/docs/terms"
|
||||
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)"
|
||||
title='服务协议 (Terms)'
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={termsSections}
|
||||
description={
|
||||
<span>
|
||||
为了保障您的合法权益,请您仔细阅读以下条款。
|
||||
</span>
|
||||
}
|
||||
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {Search} from "lucide-react"
|
||||
import {Search} from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import type {AccessLogTab, SearchDraft} from "./access-log-utils"
|
||||
import {PAGE_SIZE_OPTIONS} from "./access-log-utils"
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import type {AccessLogTab, SearchDraft} from './access-log-utils';
|
||||
import {PAGE_SIZE_OPTIONS} from './access-log-utils';
|
||||
|
||||
interface AccessLogFiltersProps {
|
||||
tab: AccessLogTab
|
||||
draft: SearchDraft
|
||||
pageSize: number
|
||||
onDraftChange: (draft: SearchDraft) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
onSearch: () => void
|
||||
onReset: () => void
|
||||
tab: AccessLogTab;
|
||||
draft: SearchDraft;
|
||||
pageSize: number;
|
||||
onDraftChange: (draft: SearchDraft) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
onSearch: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function AccessLogFilters({
|
||||
@@ -27,78 +27,84 @@ export function AccessLogFilters({
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const showPath = tab === "list" || tab === "folds"
|
||||
const showPath = tab === 'list' || tab === 'folds';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">节点 ID</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>节点 ID</p>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
value={draft.nodeId}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, nodeId: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按 node_id 搜索"
|
||||
className="pl-8 h-9 text-xs"
|
||||
placeholder='按 node_id 搜索'
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">来源 IP</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>来源 IP</p>
|
||||
<Input
|
||||
value={draft.remoteAddr}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, remoteAddr: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按 IP 搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按 IP 搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">访问域名</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>访问域名</p>
|
||||
<Input
|
||||
value={draft.host}
|
||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按域名搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按域名搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
{showPath ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">请求路径</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
请求路径
|
||||
</p>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, path: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按路径搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按路径搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1.5 w-full sm:max-w-[180px]">
|
||||
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number.parseInt(value, 10))}
|
||||
onValueChange={(value) =>
|
||||
onPageSizeChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -110,15 +116,15 @@ export function AccessLogFilters({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={onSearch}>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={onSearch}>
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onReset}>
|
||||
<Button variant='outline' size='sm' onClick={onReset}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
export type AccessLogTab = "list" | "folds" | "ip-summary" | "ip-trend"
|
||||
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string
|
||||
remoteAddr: string
|
||||
host: string
|
||||
path: string
|
||||
}
|
||||
nodeId: string;
|
||||
remoteAddr: string;
|
||||
host: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: "logged_at:desc", label: "时间从新到旧" },
|
||||
{ value: "logged_at:asc", label: "时间从旧到新" },
|
||||
{ value: "status_code:desc", label: "状态码从高到低" },
|
||||
{ value: "status_code:asc", label: "状态码从低到高" },
|
||||
{ value: "remote_addr:asc", label: "IP 正序" },
|
||||
{ value: "remote_addr:desc", label: "IP 倒序" },
|
||||
]
|
||||
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
||||
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
||||
{ value: 'status_code:desc', label: '状态码从高到低' },
|
||||
{ value: 'status_code:asc', label: '状态码从低到高' },
|
||||
{ value: 'remote_addr:asc', label: 'IP 正序' },
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
|
||||
export const FOLD_SORT_OPTIONS = [
|
||||
{ value: "bucket_started_at:desc", label: "时间桶从新到旧" },
|
||||
{ value: "bucket_started_at:asc", label: "时间桶从旧到新" },
|
||||
{ value: "request_count:desc", label: "访问次数从高到低" },
|
||||
{ value: "request_count:asc", label: "访问次数从低到高" },
|
||||
]
|
||||
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
|
||||
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
|
||||
{ value: 'request_count:desc', label: '访问次数从高到低' },
|
||||
{ value: 'request_count:asc', label: '访问次数从低到高' },
|
||||
];
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: "total_requests:desc", label: "总访问次数从高到低" },
|
||||
{ value: "total_requests:asc", label: "总访问次数从低到高" },
|
||||
{ value: "recent_requests:desc", label: "3 小时访问次数从高到低" },
|
||||
{ value: "last_seen_at:desc", label: "最后访问时间从新到旧" },
|
||||
]
|
||||
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
|
||||
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
|
||||
];
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = "logged_at", sortOrder = "desc"] = value.split(":")
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
return {
|
||||
sortBy,
|
||||
sortOrder: sortOrder === "asc" ? ("asc" as const) : ("desc" as const),
|
||||
}
|
||||
sortOrder: sortOrder === 'asc' ? ('asc' as const) : ('desc' as const),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
notation: value >= 10000 ? "compact" : "standard",
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
notation: value >= 10000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
}
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useState} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {useState} from 'react';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -11,19 +11,19 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
|
||||
const CLEANUP_PRESETS = [3, 7, 30]
|
||||
const CLEANUP_PRESETS = [3, 7, 30];
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (retentionDays: number) => void
|
||||
loading: boolean
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (retentionDays: number) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function CleanupDialog({
|
||||
@@ -32,24 +32,24 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const [mode, setMode] = useState<string>("7")
|
||||
const [customDays, setCustomDays] = useState("14")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<string>('7');
|
||||
const [customDays, setCustomDays] = useState('14');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const retentionDays =
|
||||
mode === "custom"
|
||||
mode === 'custom'
|
||||
? Number.parseInt(customDays, 10)
|
||||
: Number.parseInt(mode, 10)
|
||||
: Number.parseInt(mode, 10);
|
||||
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
setError("保留天数必须大于 0")
|
||||
return
|
||||
setError('保留天数必须大于 0');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null)
|
||||
onConfirm(retentionDays)
|
||||
}
|
||||
setError(null);
|
||||
onConfirm(retentionDays);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -61,8 +61,8 @@ export function CleanupDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>保留策略</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger>
|
||||
@@ -74,17 +74,17 @@ export function CleanupDialog({
|
||||
保留最近 {days} 天
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">自定义天数</SelectItem>
|
||||
<SelectItem value='custom'>自定义天数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="customDays">自定义保留天数</Label>
|
||||
{mode === 'custom' ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='customDays'>自定义保留天数</Label>
|
||||
<Input
|
||||
id="customDays"
|
||||
type="number"
|
||||
id='customDays'
|
||||
type='number'
|
||||
min={1}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(e.target.value)}
|
||||
@@ -93,23 +93,27 @@ export function CleanupDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
{error ? <p className='text-xs text-destructive'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
</>
|
||||
) : (
|
||||
"确认清理"
|
||||
'确认清理'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
|
||||
import {RefreshCw, ScrollText, Trash2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {AccessLogService} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AccessLogService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {AccessLogFilters} from "./components/access-log-filters"
|
||||
import { AccessLogFilters } from './components/access-log-filters';
|
||||
import {
|
||||
type AccessLogTab,
|
||||
DETAIL_SORT_OPTIONS,
|
||||
@@ -27,19 +45,19 @@ import {
|
||||
IP_SORT_OPTIONS,
|
||||
parseSortValue,
|
||||
type SearchDraft,
|
||||
} from "./components/access-log-utils"
|
||||
import {CleanupDialog} from "./components/cleanup-dialog"
|
||||
} from './components/access-log-utils';
|
||||
import { CleanupDialog } from './components/cleanup-dialog';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: "请求数", color: "hsl(var(--primary))" },
|
||||
} satisfies ChartConfig
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const emptyDraft: SearchDraft = {
|
||||
nodeId: "",
|
||||
remoteAddr: "",
|
||||
host: "",
|
||||
path: "",
|
||||
}
|
||||
nodeId: '',
|
||||
remoteAddr: '',
|
||||
host: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
function PaginationBar({
|
||||
page,
|
||||
@@ -48,48 +66,66 @@ function PaginationBar({
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dashed">
|
||||
<p className="text-xs text-muted-foreground">当前第 {page + 1} 页</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={loading || page <= 0} onClick={onPrev}>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={loading || !hasMore} onClick={onNext}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessLogsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<AccessLogTab>("list")
|
||||
const [draft, setDraft] = useState<SearchDraft>(emptyDraft)
|
||||
const [filters, setFilters] = useState<SearchDraft>(emptyDraft)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [page, setPage] = useState(0)
|
||||
const [detailSort, setDetailSort] = useState("logged_at:desc")
|
||||
const [foldSort, setFoldSort] = useState("bucket_started_at:desc")
|
||||
const [ipSort, setIpSort] = useState("total_requests:desc")
|
||||
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3)
|
||||
const [trendIp, setTrendIp] = useState("")
|
||||
const [appliedTrendIp, setAppliedTrendIp] = useState("")
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false)
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<AccessLogTab>('list');
|
||||
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
|
||||
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [page, setPage] = useState(0);
|
||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||
const [foldSort, setFoldSort] = useState('bucket_started_at:desc');
|
||||
const [ipSort, setIpSort] = useState('total_requests:desc');
|
||||
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3);
|
||||
const [trendIp, setTrendIp] = useState('');
|
||||
const [appliedTrendIp, setAppliedTrendIp] = useState('');
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
|
||||
const detailSortState = parseSortValue(detailSort)
|
||||
const foldSortState = parseSortValue(foldSort)
|
||||
const ipSortState = parseSortValue(ipSort)
|
||||
const detailSortState = parseSortValue(detailSort);
|
||||
const foldSortState = parseSortValue(foldSort);
|
||||
const ipSortState = parseSortValue(ipSort);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "list", filters, page, pageSize, detailSort],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'list',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
detailSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.list({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -101,11 +137,20 @@ export default function AccessLogsPage() {
|
||||
sort_by: detailSortState.sortBy,
|
||||
sort_order: detailSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "list",
|
||||
})
|
||||
enabled: tab === 'list',
|
||||
});
|
||||
|
||||
const foldsQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "folds", filters, page, pageSize, foldSort, foldMinutes],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'folds',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
foldSort,
|
||||
foldMinutes,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listFolds({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -118,11 +163,19 @@ export default function AccessLogsPage() {
|
||||
sort_order: foldSortState.sortOrder,
|
||||
fold_minutes: foldMinutes,
|
||||
}),
|
||||
enabled: tab === "folds",
|
||||
})
|
||||
enabled: tab === 'folds',
|
||||
});
|
||||
|
||||
const ipSummaryQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-summary", filters, page, pageSize, ipSort],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'ip-summary',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
ipSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listIPSummaries({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -133,11 +186,11 @@ export default function AccessLogsPage() {
|
||||
sort_by: ipSortState.sortBy,
|
||||
sort_order: ipSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "ip-summary",
|
||||
})
|
||||
enabled: tab === 'ip-summary',
|
||||
});
|
||||
|
||||
const ipTrendQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-trend", filters, appliedTrendIp],
|
||||
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPTrend({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -146,50 +199,52 @@ export default function AccessLogsPage() {
|
||||
hours: 24,
|
||||
bucket_minutes: 30,
|
||||
}),
|
||||
enabled: tab === "ip-trend" && appliedTrendIp !== "",
|
||||
})
|
||||
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`)
|
||||
setCleanupOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ["openflare", "access-logs"] })
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`);
|
||||
setCleanupOpen(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'access-logs'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "清理失败")
|
||||
toast.error(error instanceof Error ? error.message : '清理失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const activeSummary = useMemo(() => {
|
||||
if (tab === "list" && listQuery.data) {
|
||||
if (tab === 'list' && listQuery.data) {
|
||||
return {
|
||||
totalRecord: listQuery.data.total_record,
|
||||
totalIp: listQuery.data.total_ip,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (tab === "folds" && foldsQuery.data) {
|
||||
if (tab === 'folds' && foldsQuery.data) {
|
||||
return {
|
||||
totalRecord: foldsQuery.data.total_record,
|
||||
totalIp: foldsQuery.data.total_ip,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (tab === "ip-summary" && ipSummaryQuery.data) {
|
||||
if (tab === 'ip-summary' && ipSummaryQuery.data) {
|
||||
return {
|
||||
totalRecord: 0,
|
||||
totalIp: ipSummaryQuery.data.total_ip,
|
||||
}
|
||||
};
|
||||
}
|
||||
return { totalRecord: 0, totalIp: 0 }
|
||||
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data])
|
||||
return { totalRecord: 0, totalIp: 0 };
|
||||
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]);
|
||||
|
||||
const trendChartData = useMemo(() => {
|
||||
return (ipTrendQuery.data?.points ?? []).map((point) => ({
|
||||
label: formatDateTime(point.bucket_started_at).slice(5),
|
||||
requests: point.request_count,
|
||||
}))
|
||||
}, [ipTrendQuery.data?.points])
|
||||
}));
|
||||
}, [ipTrendQuery.data?.points]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setFilters({
|
||||
@@ -197,93 +252,116 @@ export default function AccessLogsPage() {
|
||||
remoteAddr: draft.remoteAddr.trim(),
|
||||
host: draft.host.trim(),
|
||||
path: draft.path.trim(),
|
||||
})
|
||||
setPage(0)
|
||||
}, [draft])
|
||||
});
|
||||
setPage(0);
|
||||
}, [draft]);
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(emptyDraft)
|
||||
setFilters(emptyDraft)
|
||||
setPage(0)
|
||||
setTrendIp("")
|
||||
setAppliedTrendIp("")
|
||||
}
|
||||
setDraft(emptyDraft);
|
||||
setFilters(emptyDraft);
|
||||
setPage(0);
|
||||
setTrendIp('');
|
||||
setAppliedTrendIp('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [tab, pageSize])
|
||||
setPage(0);
|
||||
}, [tab, pageSize]);
|
||||
|
||||
const refreshActive = () => {
|
||||
if (tab === "list") void listQuery.refetch()
|
||||
if (tab === "folds") void foldsQuery.refetch()
|
||||
if (tab === "ip-summary") void ipSummaryQuery.refetch()
|
||||
if (tab === "ip-trend") void ipTrendQuery.refetch()
|
||||
}
|
||||
if (tab === 'list') void listQuery.refetch();
|
||||
if (tab === 'folds') void foldsQuery.refetch();
|
||||
if (tab === 'ip-summary') void ipSummaryQuery.refetch();
|
||||
if (tab === 'ip-trend') void ipTrendQuery.refetch();
|
||||
};
|
||||
|
||||
const isFetching =
|
||||
listQuery.isFetching ||
|
||||
foldsQuery.isFetching ||
|
||||
ipSummaryQuery.isFetching ||
|
||||
ipTrendQuery.isFetching
|
||||
ipTrendQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="size-5 text-primary" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ScrollText className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">访问日志</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refreshActive} disabled={isFetching}>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${isFetching ? "animate-spin" : ""}`} />
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={refreshActive}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setCleanupOpen(true)}>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
{[
|
||||
{ label: "访问记录", value: formatCompactNumber(activeSummary.totalRecord) },
|
||||
{ label: "来源 IP", value: formatCompactNumber(activeSummary.totalIp) },
|
||||
{
|
||||
label: "当前视图",
|
||||
label: '访问记录',
|
||||
value: formatCompactNumber(activeSummary.totalRecord),
|
||||
},
|
||||
{
|
||||
label: '来源 IP',
|
||||
value: formatCompactNumber(activeSummary.totalIp),
|
||||
},
|
||||
{
|
||||
label: '当前视图',
|
||||
value:
|
||||
tab === "list"
|
||||
? "明细日志"
|
||||
: tab === "folds"
|
||||
? "时间折叠"
|
||||
: tab === "ip-summary"
|
||||
? "IP 汇总"
|
||||
: "IP 趋势",
|
||||
tab === 'list'
|
||||
? '明细日志'
|
||||
: tab === 'folds'
|
||||
? '时间折叠'
|
||||
: tab === 'ip-summary'
|
||||
? 'IP 汇总'
|
||||
: 'IP 趋势',
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-lg border border-dashed px-4 py-3'
|
||||
>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{item.value}</p>
|
||||
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as AccessLogTab)}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4">
|
||||
<TabsTrigger value="list">明细列表</TabsTrigger>
|
||||
<TabsTrigger value="folds">时间折叠</TabsTrigger>
|
||||
<TabsTrigger value="ip-summary">IP 汇总</TabsTrigger>
|
||||
<TabsTrigger value="ip-trend">IP 趋势</TabsTrigger>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as AccessLogTab)}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2 lg:grid-cols-4'>
|
||||
<TabsTrigger value='list'>明细列表</TabsTrigger>
|
||||
<TabsTrigger value='folds'>时间折叠</TabsTrigger>
|
||||
<TabsTrigger value='ip-summary'>IP 汇总</TabsTrigger>
|
||||
<TabsTrigger value='ip-trend'>IP 趋势</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-background p-4">
|
||||
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
|
||||
<AccessLogFilters
|
||||
tab={tab}
|
||||
draft={draft}
|
||||
@@ -295,12 +373,12 @@ export default function AccessLogsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
|
||||
<p className="text-sm font-medium">明细日志</p>
|
||||
<TabsContent value='list' className='mt-4'>
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>明细日志</p>
|
||||
<Select value={detailSort} onValueChange={setDetailSort}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -313,38 +391,50 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{listQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={listQuery.error instanceof Error ? listQuery.error.message : "加载失败"}
|
||||
message={
|
||||
listQuery.error instanceof Error
|
||||
? listQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : listQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (listQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无访问日志" />
|
||||
<EmptyStateWithBorder title='暂无访问日志' />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">节点</TableHead>
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">域名</TableHead>
|
||||
<TableHead className="text-xs">路径</TableHead>
|
||||
<TableHead className="text-xs">状态码</TableHead>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>时间</TableHead>
|
||||
<TableHead className='text-xs'>节点</TableHead>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>域名</TableHead>
|
||||
<TableHead className='text-xs'>路径</TableHead>
|
||||
<TableHead className='text-xs'>状态码</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id} className="border-dashed">
|
||||
<TableCell className="text-xs">{formatDateTime(item.logged_at)}</TableCell>
|
||||
<TableCell className="text-xs">{item.node_name || item.node_id}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
|
||||
<TableCell className="text-xs">{item.host}</TableCell>
|
||||
<TableCell className="text-xs max-w-48 truncate">{item.path}</TableCell>
|
||||
<TableRow key={item.id} className='border-dashed'>
|
||||
<TableCell className='text-xs'>
|
||||
{formatDateTime(item.logged_at)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.node_name || item.node_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs font-mono'>
|
||||
{item.remote_addr}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>{item.host}</TableCell>
|
||||
<TableCell className='text-xs max-w-48 truncate'>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{item.status_code}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -363,26 +453,28 @@ export default function AccessLogsPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="folds" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">时间折叠</p>
|
||||
<TabsContent value='folds' className='mt-4'>
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-sm font-medium'>时间折叠</p>
|
||||
<Select
|
||||
value={String(foldMinutes)}
|
||||
onValueChange={(value) => setFoldMinutes(Number(value) as 3 | 5)}
|
||||
onValueChange={(value) =>
|
||||
setFoldMinutes(Number(value) as 3 | 5)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectTrigger className='h-8 w-36 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3 分钟桶</SelectItem>
|
||||
<SelectItem value="5">5 分钟桶</SelectItem>
|
||||
<SelectItem value='3'>3 分钟桶</SelectItem>
|
||||
<SelectItem value='5'>5 分钟桶</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={foldSort} onValueChange={setFoldSort}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -395,12 +487,12 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{foldsQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
foldsQuery.error instanceof Error
|
||||
? foldsQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void foldsQuery.refetch()}
|
||||
/>
|
||||
@@ -408,32 +500,47 @@ export default function AccessLogsPage() {
|
||||
) : foldsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||
<EmptyStateWithBorder title='暂无折叠数据' />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间桶</TableHead>
|
||||
<TableHead className="text-xs">请求数</TableHead>
|
||||
<TableHead className="text-xs">独立 IP</TableHead>
|
||||
<TableHead className="text-xs">独立域名</TableHead>
|
||||
<TableHead className="text-xs">2xx</TableHead>
|
||||
<TableHead className="text-xs">4xx</TableHead>
|
||||
<TableHead className="text-xs">5xx</TableHead>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>时间桶</TableHead>
|
||||
<TableHead className='text-xs'>请求数</TableHead>
|
||||
<TableHead className='text-xs'>独立 IP</TableHead>
|
||||
<TableHead className='text-xs'>独立域名</TableHead>
|
||||
<TableHead className='text-xs'>2xx</TableHead>
|
||||
<TableHead className='text-xs'>4xx</TableHead>
|
||||
<TableHead className='text-xs'>5xx</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(foldsQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.bucket_started_at} className="border-dashed">
|
||||
<TableCell className="text-xs">
|
||||
<TableRow
|
||||
key={item.bucket_started_at}
|
||||
className='border-dashed'
|
||||
>
|
||||
<TableCell className='text-xs'>
|
||||
{formatDateTime(item.bucket_started_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.request_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.unique_ip_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.unique_host_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.success_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.client_error_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.server_error_count}</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.request_count}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.unique_ip_count}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.unique_host_count}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.success_count}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.client_error_count}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.server_error_count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -449,12 +556,12 @@ export default function AccessLogsPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip-summary" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
|
||||
<p className="text-sm font-medium">IP 汇总</p>
|
||||
<TabsContent value='ip-summary' className='mt-4'>
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>IP 汇总</p>
|
||||
<Select value={ipSort} onValueChange={setIpSort}>
|
||||
<SelectTrigger className="h-8 w-52 text-xs">
|
||||
<SelectTrigger className='h-8 w-52 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -467,12 +574,12 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{ipSummaryQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
ipSummaryQuery.error instanceof Error
|
||||
? ipSummaryQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void ipSummaryQuery.refetch()}
|
||||
/>
|
||||
@@ -480,24 +587,30 @@ export default function AccessLogsPage() {
|
||||
) : ipSummaryQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
||||
<EmptyStateWithBorder title='暂无 IP 汇总数据' />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">总请求数</TableHead>
|
||||
<TableHead className="text-xs">近 3 小时</TableHead>
|
||||
<TableHead className="text-xs">最后访问</TableHead>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>总请求数</TableHead>
|
||||
<TableHead className='text-xs'>近 3 小时</TableHead>
|
||||
<TableHead className='text-xs'>最后访问</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(ipSummaryQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.remote_addr} className="border-dashed">
|
||||
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
|
||||
<TableCell className="text-xs">{item.total_requests}</TableCell>
|
||||
<TableCell className="text-xs">{item.recent_requests}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<TableRow key={item.remote_addr} className='border-dashed'>
|
||||
<TableCell className='text-xs font-mono'>
|
||||
{item.remote_addr}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.total_requests}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.recent_requests}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{formatDateTime(item.last_seen_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -515,19 +628,21 @@ export default function AccessLogsPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip-trend" className="mt-4 space-y-4">
|
||||
<div className="rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">趋势 IP</p>
|
||||
<TabsContent value='ip-trend' className='mt-4 space-y-4'>
|
||||
<div className='rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end'>
|
||||
<div className='space-y-1.5 flex-1'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
趋势 IP
|
||||
</p>
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs"
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs'
|
||||
value={trendIp}
|
||||
onChange={(e) => setTrendIp(e.target.value)}
|
||||
placeholder="输入要分析趋势的 IP 地址"
|
||||
placeholder='输入要分析趋势的 IP 地址'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
size='sm'
|
||||
onClick={() => setAppliedTrendIp(trendIp.trim())}
|
||||
disabled={!trendIp.trim()}
|
||||
>
|
||||
@@ -535,9 +650,9 @@ export default function AccessLogsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background p-4">
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
|
||||
{!appliedTrendIp ? (
|
||||
<EmptyStateWithBorder description="请输入 IP 地址后查看 24 小时访问趋势。" />
|
||||
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
|
||||
) : ipTrendQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : ipTrendQuery.isError ? (
|
||||
@@ -545,28 +660,41 @@ export default function AccessLogsPage() {
|
||||
message={
|
||||
ipTrendQuery.error instanceof Error
|
||||
? ipTrendQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void ipTrendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.length === 0 ? (
|
||||
<EmptyStateWithBorder description="该 IP 在选定时间范围内没有访问记录。" />
|
||||
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
<div className='space-y-3'>
|
||||
<p className='text-sm font-medium'>
|
||||
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
|
||||
</p>
|
||||
<ChartContainer config={trendChartConfig} className="h-64 w-full">
|
||||
<ChartContainer
|
||||
config={trendChartConfig}
|
||||
className='h-64 w-full'
|
||||
>
|
||||
<AreaChart data={trendChartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={10} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={10} width={40} />
|
||||
<XAxis
|
||||
dataKey='label'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="requests"
|
||||
stroke="var(--color-requests)"
|
||||
fill="var(--color-requests)"
|
||||
type='monotone'
|
||||
dataKey='requests'
|
||||
stroke='var(--color-requests)'
|
||||
fill='var(--color-requests)'
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -584,5 +712,5 @@ export default function AccessLogsPage() {
|
||||
loading={cleanupMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
'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 * 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 {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,
|
||||
@@ -18,183 +18,205 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
} 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]
|
||||
}
|
||||
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, ",")
|
||||
}
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
|
||||
};
|
||||
|
||||
interface CacheManagerProps {
|
||||
refreshTrigger: number
|
||||
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 [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 [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)
|
||||
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)
|
||||
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 : "未知错误",
|
||||
})
|
||||
toast.error('获取磁盘缓存状态失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
setLoadingCache(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
e.preventDefault();
|
||||
const size = parseInt(maxSizeMB, 10);
|
||||
const ttl = parseInt(ttlMinutes, 10);
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
toast.error('保存失败', {
|
||||
description: '最大容量限制必须是大于等于 1 的整数',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
toast.error('保存失败', {
|
||||
description: '默认过期时间必须是大于等于 0 的整数',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSavingConfig(true)
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
await services.adminCache.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
});
|
||||
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('保存配置失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
setClearingCache(true);
|
||||
try {
|
||||
await services.adminCache.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
await services.adminCache.clearCache();
|
||||
toast.success('清空成功', { description: '缓存数据已全部清除' });
|
||||
setShowClearConfirm(false);
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('清空缓存失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
setClearingCache(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化拉取
|
||||
useEffect(() => {
|
||||
fetchCacheStatus()
|
||||
}, [fetchCacheStatus])
|
||||
fetchCacheStatus();
|
||||
}, [fetchCacheStatus]);
|
||||
|
||||
// 当外部刷新触发器改变时自动刷新
|
||||
useEffect(() => {
|
||||
fetchCacheStatus(true)
|
||||
}, [refreshTrigger, fetchCacheStatus])
|
||||
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>
|
||||
<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>
|
||||
<CardDescription className='text-[11px]'>
|
||||
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start md:self-auto"
|
||||
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" : ""}`} />
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<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 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">
|
||||
<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='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">
|
||||
<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>
|
||||
<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}>
|
||||
<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"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8 text-xs font-medium w-full'
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
@@ -202,111 +224,125 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
</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>
|
||||
<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"
|
||||
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"
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 100'
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
<p className='text-[9px] text-muted-foreground'>
|
||||
当总大小超出该值时,自动触发淘汰算法
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
|
||||
生存时间限制 (分钟)
|
||||
</Label>
|
||||
<Input
|
||||
id="ttlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
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 表示永不过期"
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 60,0 表示永不过期'
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
|
||||
<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">
|
||||
<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 className='text-[10px] text-muted-foreground block'>
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
id='lruEnabled'
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<div className='flex justify-end pt-2'>
|
||||
<Button
|
||||
type="submit"
|
||||
type='submit'
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
className='h-8 text-xs px-4'
|
||||
>
|
||||
{savingConfig ? "正在保存..." : "保存设置"}
|
||||
{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 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">
|
||||
<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 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 className='text-xs text-muted-foreground pt-1'>
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
|
||||
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<DialogFooter className='flex gap-2 justify-end mt-4'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
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"
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
{clearingCache ? '清理中...' : '确认清空'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,210 @@
|
||||
"use client"
|
||||
'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 * 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"
|
||||
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") {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return "[Object]"
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
interface SQLConsoleProps {
|
||||
dbType?: string // "postgres" | "sqlite"
|
||||
onClose: () => void
|
||||
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 [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 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
|
||||
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
|
||||
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)
|
||||
setEditorHeight(newHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
if (!sqlQuery.trim()) return;
|
||||
setExecutingSQL(true);
|
||||
setSqlResult(null);
|
||||
setSqlError(null);
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
const result = await DbManageService.executeSQL(sqlQuery);
|
||||
setSqlResult(result);
|
||||
toast.success('SQL 执行成功');
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
setSqlError(err instanceof Error ? err.message : '未知执行错误');
|
||||
toast.error('SQL 执行失败');
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
setExecutingSQL(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
setSqlQuery(query);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
onClose()
|
||||
}
|
||||
setSqlResult(null);
|
||||
setSqlError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
<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">
|
||||
<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"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<ArrowLeft className='size-4' />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Terminal className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">SQL 查询终端</h1>
|
||||
<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 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]"
|
||||
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 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-3 flex-wrap'>
|
||||
{/* 快速模板 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">快速模板:</span>
|
||||
<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 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>
|
||||
<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">
|
||||
<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="清空编辑器"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setSqlQuery('')}
|
||||
title='清空编辑器'
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
size='sm'
|
||||
className='h-7 px-3 gap-1 text-[11px]'
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
<Play className='size-3' />
|
||||
{executingSQL ? '运行中...' : '运行'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,15 +213,15 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
className='w-full overflow-hidden relative min-h-[100px] bg-background'
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
height='100%'
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
className='h-full text-xs font-mono'
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
@@ -207,82 +235,107 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
{/* 拖动分割线 */}
|
||||
<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="拖动调整大小"
|
||||
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 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-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>
|
||||
<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
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
|
||||
{sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<div className="flex-1 overflow-auto p-4 min-h-0">
|
||||
<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 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 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>
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className='font-semibold text-foreground py-2'
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</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} 毫秒。
|
||||
{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>
|
||||
)}
|
||||
@@ -290,14 +343,16 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
)}
|
||||
|
||||
{!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 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,157 +1,195 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Layers, RefreshCw} from "lucide-react"
|
||||
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"
|
||||
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, ",")
|
||||
}
|
||||
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") {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return "[Object]"
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
interface TableBrowserProps {
|
||||
tables: string[]
|
||||
loadingTables: boolean
|
||||
refreshTrigger: number
|
||||
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
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
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])
|
||||
setSelectedTable(tables[0]);
|
||||
}
|
||||
}, [tables, selectedTable])
|
||||
}, [tables, selectedTable]);
|
||||
|
||||
// 当选中的表、页码或外部刷新触发器改变时获取数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
fetchTableData(selectedTable, page, pageSize);
|
||||
}
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData]);
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
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>
|
||||
<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">
|
||||
<div className='flex items-center gap-2'>
|
||||
{loadingTables ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<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 className='h-8 w-[200px] text-xs bg-background border-border/40'>
|
||||
<SelectValue placeholder='选择数据表' />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectContent className='max-h-[300px]'>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
<SelectItem key={t} value={t} className='text-xs font-mono'>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<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 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='space-y-4'>
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
<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 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">
|
||||
<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>
|
||||
<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">
|
||||
<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])}>
|
||||
<TableCell key={col} className='font-mono py-2'>
|
||||
<span
|
||||
className='truncate max-w-[200px] block'
|
||||
title={formatCellValue(row[col])}
|
||||
>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -160,7 +198,10 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={tableData.columns.length}
|
||||
className='text-center py-10 text-muted-foreground'
|
||||
>
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -170,29 +211,36 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<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> 条记录
|
||||
总行数:{' '}
|
||||
<span className='font-mono text-foreground font-semibold'>
|
||||
{formatNumber(tableData.total)}
|
||||
</span>{' '}
|
||||
条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
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 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]"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 px-2 text-[11px]'
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
disabled={
|
||||
page >= Math.ceil(tableData.total / pageSize) || loadingData
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
@@ -200,12 +248,12 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
</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 className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
|
||||
<Layers className='size-8 opacity-45 mb-2' />
|
||||
<span className='text-xs'>未选择数据表或表结构无法加载</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import dynamic from 'next/dynamic';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
@@ -15,134 +15,142 @@ import {
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
} 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"
|
||||
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" />
|
||||
)
|
||||
<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),
|
||||
() => import('./components/table-browser').then((mod) => mod.TableBrowser),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const CacheManager = dynamic(
|
||||
() => import("./components/cache-manager").then((mod) => mod.CacheManager),
|
||||
() => import('./components/cache-manager').then((mod) => mod.CacheManager),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const SQLConsole = dynamic(
|
||||
() => import("./components/sql-console").then((mod) => mod.SQLConsole),
|
||||
() => 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, ",")
|
||||
}
|
||||
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 [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)
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState<number>(0);
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
if (!isSilent) setLoadingOverview(true);
|
||||
try {
|
||||
const data = await services.dbManage.getOverview()
|
||||
setOverview(data)
|
||||
const data = await services.dbManage.getOverview();
|
||||
setOverview(data);
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('获取数据库概览失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
setLoadingOverview(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await services.dbManage.listTables()
|
||||
setTables(data)
|
||||
const data = await services.dbManage.listTables();
|
||||
setTables(data);
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('获取数据库数据表列表失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}
|
||||
fetchOverview();
|
||||
fetchTables();
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// 4. 导出数据库备份
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
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}` })
|
||||
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 : "导出异常",
|
||||
})
|
||||
toast.error('数据库导出失败', {
|
||||
description: err instanceof Error ? err.message : '导出异常',
|
||||
});
|
||||
} finally {
|
||||
setExporting(false)
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
fetchOverview();
|
||||
fetchTables();
|
||||
}, [fetchOverview, fetchTables]);
|
||||
|
||||
// 渲染概览卡片骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<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" />
|
||||
<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" />
|
||||
<Skeleton className='h-6 w-24' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// 渲染 SQL 查询控台全屏视图
|
||||
if (showConsole) {
|
||||
@@ -151,110 +159,136 @@ export function DatabasePageClient() {
|
||||
dbType={overview?.type}
|
||||
onClose={() => setShowConsole(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 正常页面排版布局
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
<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 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>
|
||||
<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"
|
||||
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" : ""}`} />
|
||||
<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>
|
||||
{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>
|
||||
{/* 卡片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>
|
||||
{/* 卡片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>
|
||||
{/* 卡片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>
|
||||
{/* 卡片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>
|
||||
)
|
||||
)}
|
||||
{/* 卡片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
|
||||
@@ -264,60 +298,64 @@ export function DatabasePageClient() {
|
||||
/>
|
||||
|
||||
{/* 3. 缓存管理区块 */}
|
||||
<CacheManager
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
<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 className='text-[10px] text-muted-foreground'>
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
|
||||
导出为打包的 .sql 文本文件)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='h-8 text-xs gap-1'
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
|
||||
{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" />
|
||||
<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 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"
|
||||
size='sm'
|
||||
className='h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95'
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
setShowConsole(true);
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
<FileText className='size-3.5' />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
@@ -325,5 +363,5 @@ export function DatabasePageClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {DatabasePageClient} from "./page-client"
|
||||
import { DatabasePageClient } from './page-client';
|
||||
|
||||
export default function DatabasePage() {
|
||||
return <DatabasePageClient />
|
||||
}
|
||||
return <DatabasePageClient />;
|
||||
}
|
||||
|
||||
@@ -1,82 +1,176 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {Code} from "lucide-react"
|
||||
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"
|
||||
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"
|
||||
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")
|
||||
const [activeTab, setActiveTab] = React.useState('dashboard');
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
{/* 1. 标准页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-5 text-primary" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Code className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">开发规范示例</h1>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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
|
||||
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
|
||||
value='controls'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
输入控件与表单
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="focus-visible:outline-none">
|
||||
<TabsContent value='dashboard' className='focus-visible:outline-none'>
|
||||
<DashboardTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="focus-visible:outline-none">
|
||||
<TabsContent value='table' className='focus-visible:outline-none'>
|
||||
<TableTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="controls" className="focus-visible:outline-none">
|
||||
<TabsContent value='controls' className='focus-visible:outline-none'>
|
||||
<ControlsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,136 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'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"
|
||||
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",
|
||||
name: '',
|
||||
category: 'database',
|
||||
allowNotify: true,
|
||||
channel: "all",
|
||||
})
|
||||
channel: 'all',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<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>
|
||||
<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">
|
||||
<CardContent className='space-y-4'>
|
||||
{/* 输入框 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="task-name" className="text-xs font-medium text-foreground">任务名称</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='task-name'
|
||||
className='text-xs font-medium text-foreground'
|
||||
>
|
||||
任务名称
|
||||
</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
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"
|
||||
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>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
任务名称将用于后台执行记录的可读性显示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 下拉框 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-foreground">任务分类</Label>
|
||||
<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 }))}
|
||||
onValueChange={(val) =>
|
||||
setFormData((prev) => ({ ...prev, category: val }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs shadow-none bg-background focus:ring-1">
|
||||
<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>
|
||||
<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">
|
||||
<div className='flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg'>
|
||||
<Checkbox
|
||||
id="allow-notify"
|
||||
id='allow-notify'
|
||||
checked={formData.allowNotify}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allowNotify: checked === true }))}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
allowNotify: checked === true,
|
||||
}))
|
||||
}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<div className='grid gap-1'>
|
||||
<label
|
||||
htmlFor="allow-notify"
|
||||
className="text-xs font-medium text-foreground cursor-pointer select-none leading-none"
|
||||
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 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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 text-xs hover:bg-muted'
|
||||
>
|
||||
幽灵按钮 (Ghost)
|
||||
</Button>
|
||||
</div>
|
||||
@@ -103,38 +139,84 @@ export function ControlsTab() {
|
||||
</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" />
|
||||
<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>
|
||||
<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> 去除原生的重叠阴影。
|
||||
输入框 (
|
||||
<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 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>,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
|
||||
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 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> 类。
|
||||
普通主表单及新增/保存按钮,一律使用默认 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 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 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">
|
||||
@@ -146,5 +228,5 @@ export function ControlsTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,81 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'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"
|
||||
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">
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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">
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<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>
|
||||
@@ -63,23 +83,53 @@ export function DashboardTab() {
|
||||
</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" />
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
@@ -94,5 +144,5 @@ export function DashboardTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,171 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'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"
|
||||
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" },
|
||||
]
|
||||
{
|
||||
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='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 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
|
||||
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 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>
|
||||
<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>
|
||||
<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" />
|
||||
{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" />
|
||||
{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" />
|
||||
{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" />
|
||||
<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
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 rounded hover:bg-destructive/10 text-destructive'
|
||||
>
|
||||
<Trash2 className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -97,23 +176,62 @@ export function TableTab() {
|
||||
</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" />
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
@@ -138,5 +256,5 @@ export function TableTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import * as React from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
} 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 {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,
|
||||
@@ -33,207 +33,230 @@ import {
|
||||
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"
|
||||
} 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 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",
|
||||
})
|
||||
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 条更紧凑
|
||||
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])
|
||||
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),
|
||||
})
|
||||
queryKey: ['files', 'all', page, pageSize, debouncedKeyword],
|
||||
queryFn: () =>
|
||||
services.adminUpload.listUploads(
|
||||
page,
|
||||
pageSize,
|
||||
debouncedKeyword || undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const storageDriverQuery = useQuery({
|
||||
queryKey: ["admin", "storage-config", "driver"],
|
||||
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 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 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 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
|
||||
})
|
||||
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 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)
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
|
||||
toast.success('文件已删除');
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "删除失败"),
|
||||
})
|
||||
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("批量下载已开始")
|
||||
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("批量下载失败"),
|
||||
})
|
||||
onError: () => toast.error('批量下载失败'),
|
||||
});
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id)
|
||||
next.add(id);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
|
||||
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()
|
||||
}
|
||||
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='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" />
|
||||
<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"
|
||||
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("")}
|
||||
className='absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setKeyword('')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
<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}
|
||||
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 ? "取消全选" : "全选本页"}
|
||||
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 打包下载等批量操作 */}
|
||||
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||
<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"
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs px-2.5">
|
||||
<Badge variant='secondary' className='text-xs px-2.5'>
|
||||
已选 {selectedIds.size} 个
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8"
|
||||
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" />
|
||||
<Loader2 className='size-3.5 mr-1 animate-spin' />
|
||||
) : (
|
||||
<FileArchive className="size-3.5 mr-1" />
|
||||
<FileArchive className='size-3.5 mr-1' />
|
||||
)}
|
||||
打包下载
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs h-8 px-2"
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className='text-xs h-8 px-2'
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<X className='size-3.5' />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -241,131 +264,152 @@ export function FileList() {
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
|
||||
<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 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 ? "没有匹配的文件" : "您还没有上传任何文件"}
|
||||
<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">
|
||||
<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">
|
||||
<TableRow className='hover:bg-transparent border-dashed'>
|
||||
<TableHead className='w-[50px] pl-4 py-3'>
|
||||
<Checkbox
|
||||
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
|
||||
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>
|
||||
<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)
|
||||
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" : ""
|
||||
isSelected ? 'bg-sky-500/5 hover:bg-sky-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<TableCell className="pl-4 py-3">
|
||||
<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/") ? (
|
||||
<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"
|
||||
quality='low'
|
||||
variant='compact'
|
||||
className='size-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type, "size-4.5")
|
||||
getFileIcon(file.mime_type, 'size-4.5')
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
|
||||
<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}>
|
||||
<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}>
|
||||
<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">
|
||||
<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}>
|
||||
<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">
|
||||
<TableCell className='py-3 text-xs'>
|
||||
{formatFileSize(file.file_size)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs text-muted-foreground">
|
||||
<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">
|
||||
<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="查看详情"
|
||||
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" />
|
||||
<Eye className='size-3.5' />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted"
|
||||
title="下载"
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-muted'
|
||||
title='下载'
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
<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="删除"
|
||||
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" />
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -374,23 +418,23 @@ export function FileList() {
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<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"
|
||||
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">
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-7 px-3"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='border-dashed text-xs h-7 px-3'
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
@@ -400,29 +444,34 @@ export function FileList() {
|
||||
)}
|
||||
|
||||
{/* 详情查看 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">
|
||||
<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>
|
||||
<SheetDescription>
|
||||
查看文件的完整元数据与配置属性。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailTarget && (
|
||||
<div className="space-y-6 py-4">
|
||||
<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/") ? (
|
||||
<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"
|
||||
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">
|
||||
<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>
|
||||
@@ -430,76 +479,123 @@ export function FileList() {
|
||||
</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()} 字节)
|
||||
<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">MIME 类型</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
|
||||
<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 text-foreground/90">{detailTarget.extension}</span>
|
||||
<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">Hash (SHA-256)</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
|
||||
<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">业务类型</span>
|
||||
<span className="col-span-2">
|
||||
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
|
||||
<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 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 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 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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -507,29 +603,40 @@ export function FileList() {
|
||||
</Sheet>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{" "}
|
||||
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
|
||||
确定要删除文件{' '}
|
||||
<span className='font-semibold text-foreground'>
|
||||
「{deleteTarget?.file_name}」
|
||||
</span>{' '}
|
||||
吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
)}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
'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 * 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 {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
@@ -14,265 +14,322 @@ import {
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
} from '@/components/ui/chart';
|
||||
import services, {formatFileSize} from '@/lib/services';
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
"图片": "images",
|
||||
"视频": "videos",
|
||||
"音频": "audio",
|
||||
"文档": "documents",
|
||||
"压缩包": "archives",
|
||||
"其他": "others",
|
||||
}
|
||||
图片: 'images',
|
||||
视频: 'videos',
|
||||
音频: 'audio',
|
||||
文档: 'documents',
|
||||
压缩包: 'archives',
|
||||
其他: 'others',
|
||||
};
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: "图片",
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: '图片',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
videos: {
|
||||
label: "视频",
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: '视频',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
audio: {
|
||||
label: "音频",
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: '音频',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
documents: {
|
||||
label: "文档",
|
||||
color: "hsl(var(--chart-4))",
|
||||
label: '文档',
|
||||
color: 'hsl(var(--chart-4))',
|
||||
},
|
||||
archives: {
|
||||
label: "压缩包",
|
||||
color: "hsl(var(--chart-5))",
|
||||
label: '压缩包',
|
||||
color: 'hsl(var(--chart-5))',
|
||||
},
|
||||
others: {
|
||||
label: "其他",
|
||||
color: "hsl(var(--chart-6))",
|
||||
label: '其他',
|
||||
color: 'hsl(var(--chart-6))',
|
||||
},
|
||||
count: {
|
||||
label: "新增文件数",
|
||||
color: "hsl(var(--primary))",
|
||||
label: '新增文件数',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
size: {
|
||||
label: "新增大小",
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: '新增大小',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function FileStats() {
|
||||
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
|
||||
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
|
||||
'count',
|
||||
);
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["files", "stats"],
|
||||
queryKey: ['files', 'stats'],
|
||||
queryFn: () => services.adminUpload.getFileStats(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
})
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
const stats = statsQuery.data
|
||||
const stats = statsQuery.data;
|
||||
|
||||
const trendData = React.useMemo(() => {
|
||||
if (!stats?.trend) return []
|
||||
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])
|
||||
value: trendMetric === 'count' ? item.count : item.size,
|
||||
}));
|
||||
}, [stats?.trend, trendMetric]);
|
||||
|
||||
const categoryCountData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
if (!stats?.categories) return [];
|
||||
return stats.categories
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
const key = categoryMap[c.name] || 'others';
|
||||
return {
|
||||
name: key,
|
||||
value: c.count,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
};
|
||||
});
|
||||
}, [stats?.categories]);
|
||||
|
||||
const categorySizeData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
if (!stats?.categories) return [];
|
||||
return stats.categories
|
||||
.filter((c) => c.size > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
const key = categoryMap[c.name] || 'others';
|
||||
return {
|
||||
name: key,
|
||||
value: c.size,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
};
|
||||
});
|
||||
}, [stats?.categories]);
|
||||
|
||||
const maxStats = React.useMemo(() => {
|
||||
if (!stats?.categories || stats.categories.length === 0) {
|
||||
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
|
||||
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
|
||||
}
|
||||
let maxCountName = "无"
|
||||
let maxCount = 0
|
||||
let maxSizeName = "无"
|
||||
let 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
|
||||
maxCount = cat.count;
|
||||
maxCountName = cat.name;
|
||||
}
|
||||
if (cat.size > maxSize) {
|
||||
maxSize = cat.size
|
||||
maxSizeName = cat.name
|
||||
maxSize = cat.size;
|
||||
maxSizeName = cat.name;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize }
|
||||
}, [stats?.categories])
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize };
|
||||
}, [stats?.categories]);
|
||||
|
||||
const trendSummary = React.useMemo(() => {
|
||||
if (!stats?.trend) return { count: 0, size: 0 }
|
||||
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])
|
||||
{ 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 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 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='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" />
|
||||
<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 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>
|
||||
<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" />
|
||||
<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 className='text-2xl font-bold font-mono text-emerald-500'>
|
||||
{formatFileSize(stats.total_size)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
|
||||
<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>
|
||||
<HardDrive className="size-4 text-amber-500" />
|
||||
<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-amber-500">+{formatFileSize(trendSummary.size)}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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")}
|
||||
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")}
|
||||
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 }}>
|
||||
<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 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 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)" />
|
||||
<CartesianGrid
|
||||
strokeDasharray='3 3'
|
||||
vertical={false}
|
||||
stroke='hsl(var(--border)/40)'
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
dataKey='formattedDate'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
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())}
|
||||
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickFormatter={(v) =>
|
||||
trendMetric === 'size' ? formatFileSize(v) : v.toString()
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
@@ -280,37 +337,52 @@ export function FileStats() {
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
|
||||
const label =
|
||||
trendMetric === 'count' ? '新增文件数' : '新增大小';
|
||||
const formattedValue =
|
||||
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
|
||||
trendMetric === 'size'
|
||||
? formatFileSize(Number(value))
|
||||
: `${value} 个`;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span
|
||||
className="size-2 rounded-full animate-pulse"
|
||||
className='size-2 rounded-full animate-pulse'
|
||||
style={{
|
||||
backgroundColor:
|
||||
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
|
||||
trendMetric === 'count'
|
||||
? 'var(--color-count)'
|
||||
: 'var(--color-size)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className='text-muted-foreground'>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
<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)"}
|
||||
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)"}
|
||||
fill={
|
||||
trendMetric === 'count'
|
||||
? 'url(#colorCount)'
|
||||
: 'url(#colorSize)'
|
||||
}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
@@ -319,30 +391,37 @@ export function FileStats() {
|
||||
</Card>
|
||||
|
||||
{/* 两个饼图分布 */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
{/* 饼图 1: 格式数量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件格式数量分布
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件个数占比
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
<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 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">
|
||||
<div className='h-[240px] w-full'>
|
||||
<ChartContainer
|
||||
config={statsChartConfig}
|
||||
className='w-full h-full'
|
||||
>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryCountData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
cx='50%'
|
||||
cy='50%'
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
dataKey='value'
|
||||
>
|
||||
{categoryCountData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
@@ -354,66 +433,88 @@ export function FileStats() {
|
||||
<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))"
|
||||
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 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">
|
||||
<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"
|
||||
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> 个文件。
|
||||
<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">
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件占用容量分布
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件大小占比
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
<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 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">
|
||||
<div className='h-[240px] w-full'>
|
||||
<ChartContainer
|
||||
config={statsChartConfig}
|
||||
className='w-full h-full'
|
||||
>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categorySizeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
cx='50%'
|
||||
cy='50%'
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
dataKey='value'
|
||||
>
|
||||
{categorySizeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
@@ -425,38 +526,52 @@ export function FileStats() {
|
||||
<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))"
|
||||
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 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">
|
||||
<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"
|
||||
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>{" "}
|
||||
<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>
|
||||
@@ -464,5 +579,5 @@ export function FileStats() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,104 +1,130 @@
|
||||
"use client"
|
||||
'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 * 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 { 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"
|
||||
TaskExecutionStatus,
|
||||
} from '@/lib/services/admin/types';
|
||||
|
||||
const storageConfigKey = "storage_config"
|
||||
const storageMigrationTaskType = "storage_migration"
|
||||
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",
|
||||
}
|
||||
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: "",
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
path_style: false,
|
||||
key_prefix: "",
|
||||
cdn_url: "",
|
||||
}
|
||||
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}
|
||||
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},
|
||||
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 || "",
|
||||
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
|
||||
}
|
||||
state: 'idle' | TaskExecutionStatus;
|
||||
id?: string;
|
||||
task_id?: string;
|
||||
source_driver?: StorageDriver;
|
||||
target_driver?: StorageDriver;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StorageMigrationPayload = {
|
||||
target: StorageConfig
|
||||
}
|
||||
target: StorageConfig;
|
||||
};
|
||||
|
||||
function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
|
||||
return parsed.target ? normalizeConfig(parsed.target) : undefined
|
||||
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>;
|
||||
return parsed.target ? normalizeConfig(parsed.target) : undefined;
|
||||
} catch {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : "未知"
|
||||
return driver ? driverLabels[driver] : '未知';
|
||||
}
|
||||
|
||||
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
|
||||
const execution = executions[0]
|
||||
if (!execution || execution.status === "succeeded") {
|
||||
return {state: "idle"}
|
||||
function latestMigration(
|
||||
executions: TaskExecution[],
|
||||
current?: StorageConfig,
|
||||
): StorageMigrationView {
|
||||
const execution = executions[0];
|
||||
if (!execution || execution.status === 'succeeded') {
|
||||
return { state: 'idle' };
|
||||
}
|
||||
const target = parseMigrationPayload(execution.payload)
|
||||
const target = parseMigrationPayload(execution.payload);
|
||||
return {
|
||||
state: execution.status,
|
||||
id: execution.id,
|
||||
@@ -106,227 +132,339 @@ function latestMigration(executions: TaskExecution[], current?: StorageConfig):
|
||||
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 queryClient = useQueryClient();
|
||||
const [config, setConfig] = React.useState<StorageConfig | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "storage-config"],
|
||||
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)
|
||||
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
|
||||
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)
|
||||
const normalized = normalizeConfig(query.data.config);
|
||||
if (current) {
|
||||
return {
|
||||
...normalized,
|
||||
driver: current.driver,
|
||||
}
|
||||
};
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
}, [query.data?.config])
|
||||
}, [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"]})
|
||||
toast.success('存储配置已保存');
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存存储配置失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
toast.error(error.message || '保存存储配置失败');
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
const payload: StorageMigrationPayload = {target: value}
|
||||
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"]})
|
||||
toast.success('存储迁移任务已下发');
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "下发存储迁移任务失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
toast.error(error.message || '下发存储迁移任务失败');
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
|
||||
mutationFn: (executionID: string) =>
|
||||
services.adminTask.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已重新下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
toast.success('存储迁移任务已重新下发');
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
|
||||
})
|
||||
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
|
||||
});
|
||||
|
||||
if (query.isPending || !config) {
|
||||
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
|
||||
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 migration = query.data?.migration;
|
||||
const isReadOnly = migration ? migration.state !== 'idle' : false;
|
||||
const isFormDisabled =
|
||||
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
|
||||
(migration
|
||||
? migration.state === 'pending' || migration.state === 'running'
|
||||
: false) ||
|
||||
saveMutation.isPending ||
|
||||
migrateMutation.isPending
|
||||
migrateMutation.isPending;
|
||||
|
||||
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
|
||||
setConfig((current) => current ? {
|
||||
...current,
|
||||
[driver]: {...current[driver], ...patch},
|
||||
} : current)
|
||||
}
|
||||
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">
|
||||
<div className='flex w-full flex-col gap-6'>
|
||||
{isReadOnly && migration && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
存储维护模式
|
||||
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
|
||||
<Badge
|
||||
variant={
|
||||
migration.state === 'failed' ? 'destructive' : 'secondary'
|
||||
}
|
||||
>
|
||||
{migration.state}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
|
||||
{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 || "尚未下发"}
|
||||
<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>}
|
||||
{migration.error && (
|
||||
<p className='text-sm text-destructive'>{migration.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
|
||||
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" />}
|
||||
{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" />
|
||||
<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">
|
||||
<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>
|
||||
<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})}
|
||||
onValueChange={(value) =>
|
||||
setConfig({ ...config, driver: value as StorageDriver })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-dashed bg-card text-xs">
|
||||
<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>
|
||||
))}
|
||||
{(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">
|
||||
{config.driver === 'local' && (
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label="根目录"
|
||||
label='根目录'
|
||||
value={config.local.root}
|
||||
placeholder="."
|
||||
onChange={(root) => setConfig({...config, local: {root}})}
|
||||
placeholder='.'
|
||||
onChange={(root) =>
|
||||
setConfig({ ...config, local: { root } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
|
||||
{(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)}
|
||||
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" && (
|
||||
{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 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="openflare" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
|
||||
<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='openflare'
|
||||
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">
|
||||
<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 className='text-xs text-amber-500 mr-auto text-left max-w-md'>
|
||||
⚠️
|
||||
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant='outline'
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => saveMutation.mutate(config)}
|
||||
className="border-dashed"
|
||||
className='border-dashed'
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 data-icon='inline-start' className='animate-spin' />
|
||||
) : (
|
||||
<Save data-icon='inline-start' />
|
||||
)}
|
||||
保存配置
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
@@ -334,7 +472,11 @@ export function StorageConfigTab() {
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => migrateMutation.mutate(config)}
|
||||
>
|
||||
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
{migrateMutation.isPending ? (
|
||||
<Loader2 data-icon='inline-start' className='animate-spin' />
|
||||
) : (
|
||||
<Play data-icon='inline-start' />
|
||||
)}
|
||||
开始迁移
|
||||
</Button>
|
||||
)}
|
||||
@@ -342,7 +484,7 @@ export function StorageConfigTab() {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectFields({
|
||||
@@ -350,41 +492,87 @@ function ObjectFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
driver: "s3" | "r2" | "minio" | "oss"
|
||||
value: ObjectStorageConfig
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void
|
||||
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})} />
|
||||
{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})} />
|
||||
{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>
|
||||
<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})} />
|
||||
<Switch
|
||||
checked={value.path_style}
|
||||
onCheckedChange={(path_style) => onChange({ path_style })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
@@ -392,24 +580,24 @@ function TextField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
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>
|
||||
<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"
|
||||
className='border-dashed bg-card text-xs'
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {FolderOpen} from "lucide-react"
|
||||
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"
|
||||
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")
|
||||
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"
|
||||
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 className='flex items-center gap-2'>
|
||||
<FolderOpen className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">存储管理</h1>
|
||||
<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">
|
||||
<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 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
|
||||
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 value='stats' className='outline-hidden'>
|
||||
{activeTab === 'stats' ? <FileStats /> : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* ──────── TAB 2: 文件列表 ──────── */}
|
||||
<TabsContent value="list" className="outline-hidden">
|
||||
{activeTab === "list" ? <FileList /> : null}
|
||||
<TabsContent value='list' className='outline-hidden'>
|
||||
{activeTab === 'list' ? <FileList /> : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="outline-hidden">
|
||||
{activeTab === "storage" ? <StorageConfigTab /> : null}
|
||||
<TabsContent value='storage' className='outline-hidden'>
|
||||
{activeTab === 'storage' ? <StorageConfigTab /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {AdminUsersProvider} from "@/contexts/admin-users-context"
|
||||
import {RequireAdminAuth} from "@/components/auth/require-auth"
|
||||
import { AdminUsersProvider } from '@/contexts/admin-users-context';
|
||||
import { RequireAdminAuth } from '@/components/auth/require-auth';
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<RequireAdminAuth>
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
<AdminUsersProvider>{children}</AdminUsersProvider>
|
||||
</RequireAdminAuth>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,209 +14,264 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react"
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle,} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
|
||||
|
||||
import services from "@/lib/services"
|
||||
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 {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import services from '@/lib/services';
|
||||
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 {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent,} from '@/components/ui/chart';
|
||||
import {Spinner} from '@/components/ui/spinner';
|
||||
|
||||
interface TrendData {
|
||||
date: string
|
||||
count: number
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface BrowserData {
|
||||
browser: string
|
||||
count: number
|
||||
browser: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TopUserData {
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
count: number
|
||||
user_id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "请求量",
|
||||
color: "hsl(var(--primary))",
|
||||
label: '请求量',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Format YYYY-MM-DD to MM/DD
|
||||
function formatDateLabel(dateStr: string) {
|
||||
if (!dateStr || dateStr.length < 10) return dateStr
|
||||
const parts = dateStr.split("-")
|
||||
if (!dateStr || dateStr.length < 10) return dateStr;
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
return `${parts[1]}/${parts[2]}`;
|
||||
}
|
||||
return dateStr
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function AccessAnalytics() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
|
||||
|
||||
const [trend, setTrend] = useState<TrendData[]>([])
|
||||
const [browsers, setBrowsers] = useState<BrowserData[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUserData[]>([])
|
||||
const [trend, setTrend] = useState<TrendData[]>([]);
|
||||
const [browsers, setBrowsers] = useState<BrowserData[]>([]);
|
||||
const [topUsers, setTopUsers] = useState<TopUserData[]>([]);
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await services.adminLog.getLogsAnalytics()
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await services.adminLog.getLogsAnalytics();
|
||||
|
||||
// Formats the date labels for the X-axis representation
|
||||
const formattedTrend = (data.trend || []).map(item => ({
|
||||
const formattedTrend = (data.trend || []).map((item) => ({
|
||||
...item,
|
||||
formattedDate: formatDateLabel(item.date)
|
||||
}))
|
||||
formattedDate: formatDateLabel(item.date),
|
||||
}));
|
||||
|
||||
setTrend(formattedTrend)
|
||||
setBrowsers(data.browsers || [])
|
||||
setTopUsers(data.top_users || [])
|
||||
setClickhouseDisabled(false)
|
||||
setTrend(formattedTrend);
|
||||
setBrowsers(data.browsers || []);
|
||||
setTopUsers(data.top_users || []);
|
||||
setClickhouseDisabled(false);
|
||||
} catch (err) {
|
||||
const errorInstance = err instanceof Error ? err : new Error("获取数据统计失败")
|
||||
const errMsg = errorInstance.message || ""
|
||||
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
|
||||
setClickhouseDisabled(true)
|
||||
const errorInstance =
|
||||
err instanceof Error ? err : new Error('获取数据统计失败');
|
||||
const errMsg = errorInstance.message || '';
|
||||
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
|
||||
setClickhouseDisabled(true);
|
||||
} else {
|
||||
setError(errorInstance)
|
||||
setError(errorInstance);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics()
|
||||
}, [fetchAnalytics])
|
||||
fetchAnalytics();
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0)
|
||||
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0)
|
||||
const totalBrowserRequests = browsers.reduce(
|
||||
(sum, item) => sum + item.count,
|
||||
0,
|
||||
);
|
||||
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
|
||||
<XCircle className='size-10 text-muted-foreground mb-3' />
|
||||
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
|
||||
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
|
||||
当前系统配置未启用 ClickHouse
|
||||
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
|
||||
配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={fetchAnalytics} />
|
||||
return <ErrorInline error={error} onRetry={fetchAnalytics} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingStateWithBorder icon={BarChart3} description="加载访问统计指标中..." />
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={BarChart3}
|
||||
description='加载访问统计指标中...'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* Overview Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">近 7 天总请求数</CardTitle>
|
||||
<TrendingUp className="size-4 text-muted-foreground" />
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<Card className='bg-card/25 border-border/40'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
近 7 天总请求数
|
||||
</CardTitle>
|
||||
<TrendingUp className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{totalTrendRequests.toLocaleString()}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">系统记录的所有成功认证的访问总频次</p>
|
||||
<div className='text-2xl font-bold font-mono'>
|
||||
{totalTrendRequests.toLocaleString()}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
系统记录的所有成功认证的访问总频次
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃终端分类数</CardTitle>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<Card className='bg-card/25 border-border/40'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
活跃终端分类数
|
||||
</CardTitle>
|
||||
<Globe className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{browsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">在一周内发起请求的浏览器代理大类统计</p>
|
||||
<div className='text-2xl font-bold font-mono'>
|
||||
{browsers.length}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
在一周内发起请求的浏览器代理大类统计
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃独立用户数</CardTitle>
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
活跃独立用户数
|
||||
</CardTitle>
|
||||
<Users className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{topUsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">一周内累计发起高频请求的注册账户总量</p>
|
||||
<div className='text-2xl font-bold font-mono'>
|
||||
{topUsers.length}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
一周内累计发起高频请求的注册账户总量
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Access Trend Chart */}
|
||||
<Card className="bg-card/20 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className='bg-card/20 border-border/40'>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold">一周访问量趋势</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-base font-bold'>
|
||||
一周访问量趋势
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
展现系统最近 7 天内的每日 API 请求曲线
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={fetchAnalytics} disabled={loading}>
|
||||
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4 text-muted-foreground" />}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={fetchAnalytics}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className='size-4' />
|
||||
) : (
|
||||
<RefreshCw className='size-4 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2 pr-4 pt-2">
|
||||
<CardContent className='pl-2 pr-4 pt-2'>
|
||||
{trend.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={TrendingUp} description="暂无趋势数据" />
|
||||
<EmptyStateWithBorder
|
||||
icon={TrendingUp}
|
||||
description='暂无趋势数据'
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] w-full">
|
||||
<ChartContainer config={chartConfig} className="w-full h-full aspect-auto">
|
||||
<div className='h-[280px] w-full'>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className='w-full h-full aspect-auto'
|
||||
>
|
||||
<AreaChart
|
||||
data={trend}
|
||||
margin={{ top: 10, right: 10, left: -20, 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 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>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<CartesianGrid strokeDasharray='3 3' vertical={false} />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
dataKey='formattedDate'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dy={10}
|
||||
className="font-mono"
|
||||
className='font-mono'
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dx={-10}
|
||||
className="font-mono"
|
||||
className='font-mono'
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="var(--color-count)"
|
||||
type='monotone'
|
||||
dataKey='count'
|
||||
stroke='var(--color-count)'
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCount)"
|
||||
name="请求次数"
|
||||
fill='url(#colorCount)'
|
||||
name='请求次数'
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
@@ -226,46 +281,56 @@ export function AccessAnalytics() {
|
||||
</Card>
|
||||
|
||||
{/* Two Columns for Ranking Statistics */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
{/* Browser Rankings */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Globe className="size-4.5 text-muted-foreground" />
|
||||
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
|
||||
<Globe className='size-4.5 text-muted-foreground' />
|
||||
使用的浏览器排行
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardDescription className='text-xs'>
|
||||
基于请求头 User-Agent 智能分类的一周占比统计
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<CardContent className='flex-1'>
|
||||
{browsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Globe} description="暂无浏览器排行数据" />
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description='暂无浏览器排行数据'
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{browsers.map((item, index) => {
|
||||
const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0
|
||||
const percent =
|
||||
totalBrowserRequests > 0
|
||||
? (item.count / totalBrowserRequests) * 100
|
||||
: 0;
|
||||
return (
|
||||
<div key={item.browser} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="px-1.5 py-0 h-5 font-mono text-[10px] select-none">
|
||||
<div key={item.browser} className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between text-xs font-medium'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='px-1.5 py-0 h-5 font-mono text-[10px] select-none'
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.browser}</span>
|
||||
<span className='font-semibold'>{item.browser}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}%)
|
||||
<span className='text-muted-foreground font-mono text-xs'>
|
||||
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
className='h-full bg-primary rounded-full transition-all duration-500'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -273,39 +338,50 @@ export function AccessAnalytics() {
|
||||
</Card>
|
||||
|
||||
{/* Top Users */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Users className="size-4.5 text-muted-foreground" />
|
||||
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
|
||||
<Users className='size-4.5 text-muted-foreground' />
|
||||
最活跃的用户排行 (Top 10)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardDescription className='text-xs'>
|
||||
统计最近一周发起接口访问请求数量最多的账户
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<CardContent className='flex-1'>
|
||||
{topUsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Users} description="暂无活跃用户数据" />
|
||||
<EmptyStateWithBorder
|
||||
icon={Users}
|
||||
description='暂无活跃用户数据'
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className='divide-y divide-border/40'>
|
||||
{topUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none">
|
||||
{(user.username || "U").slice(0, 1).toUpperCase()}
|
||||
<div
|
||||
key={user.user_id}
|
||||
className='flex items-center justify-between py-2.5 first:pt-0 last:pb-0'
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none'>
|
||||
{(user.username || 'U').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate max-w-[180px] sm:max-w-xs">
|
||||
<div className="text-xs font-bold leading-tight truncate">
|
||||
{user.username || "未知"}
|
||||
<div className='truncate max-w-[180px] sm:max-w-xs'>
|
||||
<div className='text-xs font-bold leading-tight truncate'>
|
||||
{user.username || '未知'}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground leading-normal truncate">
|
||||
{user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id}
|
||||
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
|
||||
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
|
||||
ID: {user.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-xs font-bold font-mono">{user.count.toLocaleString()}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">次请求</div>
|
||||
<div className='text-right shrink-0'>
|
||||
<div className='text-xs font-bold font-mono'>
|
||||
{user.count.toLocaleString()}
|
||||
</div>
|
||||
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
次请求
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -315,5 +391,5 @@ export function AccessAnalytics() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,441 +14,518 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {toast} from 'sonner';
|
||||
import {format} from 'date-fns';
|
||||
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle,} from 'lucide-react';
|
||||
|
||||
import services from "@/lib/services"
|
||||
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 {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import services from '@/lib/services';
|
||||
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 {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
|
||||
interface AccessLog {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
path: string
|
||||
method: string
|
||||
ip: string
|
||||
user_agent: string
|
||||
headers: string
|
||||
status: number
|
||||
latency: number
|
||||
created_at: string
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
path: string;
|
||||
method: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
headers: string;
|
||||
status: number;
|
||||
latency: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
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")
|
||||
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 formatLatency(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function statusVariant(status: number) {
|
||||
if (status >= 500) return "destructive"
|
||||
if (status >= 400) return "destructive"
|
||||
if (status >= 300) return "outline"
|
||||
return "secondary"
|
||||
if (status >= 500) return 'destructive';
|
||||
if (status >= 400) return 'destructive';
|
||||
if (status >= 300) return 'outline';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function isClickhouseDisabledError(error: Error) {
|
||||
const errMsg = error.message || ""
|
||||
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
|
||||
const errMsg = error.message || '';
|
||||
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 搜索过滤条件
|
||||
const [usernameFilter, setUsernameFilter] = useState("")
|
||||
const [pathFilter, setPathFilter] = useState("")
|
||||
const [startTimeFilter, setStartTimeFilter] = useState("")
|
||||
const [endTimeFilter, setEndTimeFilter] = useState("")
|
||||
// 搜索过滤条件
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const [pathFilter, setPathFilter] = useState('');
|
||||
const [startTimeFilter, setStartTimeFilter] = useState('');
|
||||
const [endTimeFilter, setEndTimeFilter] = useState('');
|
||||
|
||||
// 实际提交的搜索条件
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
// 实际提交的搜索条件
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
username: '',
|
||||
path: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ["admin", "access-logs", page, searchParams],
|
||||
queryFn: async () => {
|
||||
let startISO = ""
|
||||
if (searchParams.start_time) {
|
||||
startISO = new Date(searchParams.start_time).toISOString()
|
||||
}
|
||||
let endISO = ""
|
||||
if (searchParams.end_time) {
|
||||
endISO = new Date(searchParams.end_time).toISOString()
|
||||
}
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['admin', 'access-logs', page, searchParams],
|
||||
queryFn: async () => {
|
||||
let startISO = '';
|
||||
if (searchParams.start_time) {
|
||||
startISO = new Date(searchParams.start_time).toISOString();
|
||||
}
|
||||
let endISO = '';
|
||||
if (searchParams.end_time) {
|
||||
endISO = new Date(searchParams.end_time).toISOString();
|
||||
}
|
||||
|
||||
return services.adminLog.getAccessLogs({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
username: searchParams.username || undefined,
|
||||
path: searchParams.path || undefined,
|
||||
start_time: startISO || undefined,
|
||||
end_time: endISO || undefined,
|
||||
})
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
return services.adminLog.getAccessLogs({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
username: searchParams.username || undefined,
|
||||
path: searchParams.path || undefined,
|
||||
start_time: startISO || undefined,
|
||||
end_time: endISO || undefined,
|
||||
});
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const logs = logsQuery.data?.list ?? []
|
||||
const total = logsQuery.data?.total ?? 0
|
||||
const loading = logsQuery.isPending || logsQuery.isFetching
|
||||
const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error)
|
||||
const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null
|
||||
const logs = logsQuery.data?.list ?? [];
|
||||
const total = logsQuery.data?.total ?? 0;
|
||||
const loading = logsQuery.isPending || logsQuery.isFetching;
|
||||
const clickhouseDisabled =
|
||||
logsQuery.isError && isClickhouseDisabledError(logsQuery.error);
|
||||
const error =
|
||||
logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null;
|
||||
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
|
||||
[total],
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: usernameFilter.trim(),
|
||||
path: pathFilter.trim(),
|
||||
start_time: startTimeFilter,
|
||||
end_time: endTimeFilter,
|
||||
})
|
||||
}
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
setSearchParams({
|
||||
username: usernameFilter.trim(),
|
||||
path: pathFilter.trim(),
|
||||
start_time: startTimeFilter,
|
||||
end_time: endTimeFilter,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setUsernameFilter("")
|
||||
setPathFilter("")
|
||||
setStartTimeFilter("")
|
||||
setEndTimeFilter("")
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
}
|
||||
const handleReset = () => {
|
||||
setUsernameFilter('');
|
||||
setPathFilter('');
|
||||
setStartTimeFilter('');
|
||||
setEndTimeFilter('');
|
||||
setPage(1);
|
||||
setSearchParams({
|
||||
username: '',
|
||||
path: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`${subject}已复制到剪贴板`)
|
||||
}
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${subject}已复制到剪贴板`);
|
||||
};
|
||||
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return "暂无头部数据"
|
||||
try {
|
||||
const parsed = JSON.parse(headersRaw)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return headersRaw
|
||||
}
|
||||
}
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return '暂无头部数据';
|
||||
try {
|
||||
const parsed = JSON.parse(headersRaw);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return headersRaw;
|
||||
}
|
||||
};
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
|
||||
<XCircle className='size-10 text-muted-foreground mb-3' />
|
||||
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
|
||||
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
|
||||
当前系统配置未启用 ClickHouse
|
||||
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
|
||||
配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<form onSubmit={handleSearch} className="grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-user" className="text-xs">用户名</Label>
|
||||
<Input
|
||||
id="search-user"
|
||||
placeholder="输入用户名搜索..."
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-path" className="text-xs">接口路径</Label>
|
||||
<Input
|
||||
id="search-path"
|
||||
placeholder="模糊匹配路径..."
|
||||
value={pathFilter}
|
||||
onChange={(e) => setPathFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-start" className="text-xs">起始时间</Label>
|
||||
<Input
|
||||
id="search-start"
|
||||
type="datetime-local"
|
||||
value={startTimeFilter}
|
||||
onChange={(e) => setStartTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-end" className="text-xs">结束时间</Label>
|
||||
<Input
|
||||
id="search-end"
|
||||
type="datetime-local"
|
||||
value={endTimeFilter}
|
||||
onChange={(e) => setEndTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="h-8 flex-1">
|
||||
<Search className="size-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleReset} className="h-8 flex-1">
|
||||
<RotateCcw className="size-3.5 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* Filters */}
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className='grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end'
|
||||
>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-user' className='text-xs'>
|
||||
用户名
|
||||
</Label>
|
||||
<Input
|
||||
id='search-user'
|
||||
placeholder='输入用户名搜索...'
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-path' className='text-xs'>
|
||||
接口路径
|
||||
</Label>
|
||||
<Input
|
||||
id='search-path'
|
||||
placeholder='模糊匹配路径...'
|
||||
value={pathFilter}
|
||||
onChange={(e) => setPathFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-start' className='text-xs'>
|
||||
起始时间
|
||||
</Label>
|
||||
<Input
|
||||
id='search-start'
|
||||
type='datetime-local'
|
||||
value={startTimeFilter}
|
||||
onChange={(e) => setStartTimeFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-end' className='text-xs'>
|
||||
结束时间
|
||||
</Label>
|
||||
<Input
|
||||
id='search-end'
|
||||
type='datetime-local'
|
||||
value={endTimeFilter}
|
||||
onChange={(e) => setEndTimeFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button type='submit' size='sm' className='h-8 flex-1'>
|
||||
<Search className='size-3.5 mr-1' />
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleReset}
|
||||
className='h-8 flex-1'
|
||||
>
|
||||
<RotateCcw className='size-3.5 mr-1' />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Loading Table */}
|
||||
{loading && logs.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载访问日志中..." />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="未查询到任何访问日志数据" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">请求方法</TableHead>
|
||||
<TableHead className="min-w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[150px]">用户</TableHead>
|
||||
<TableHead className="w-[120px]">IP</TableHead>
|
||||
<TableHead className="w-[90px]">状态</TableHead>
|
||||
<TableHead className="w-[100px]">耗时</TableHead>
|
||||
<TableHead className="w-[170px]">请求时间</TableHead>
|
||||
<TableHead className="w-[80px] text-center">详情</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono font-semibold uppercase text-xs">
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs break-all max-w-[280px]">
|
||||
{log.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{log.username || "未知用户"}</span>
|
||||
{log.nickname && (
|
||||
<span className="text-[10px] text-muted-foreground">({log.nickname})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(log.status)}>
|
||||
{log.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatLatency(log.latency)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setSelectedLog(log)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Table */}
|
||||
{loading && logs.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载访问日志中...'
|
||||
/>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
description='未查询到任何访问日志数据'
|
||||
/>
|
||||
) : (
|
||||
<div className='rounded-lg border bg-card overflow-hidden'>
|
||||
<Table className='min-w-[900px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[100px]'>请求方法</TableHead>
|
||||
<TableHead className='min-w-[200px]'>路径</TableHead>
|
||||
<TableHead className='w-[150px]'>用户</TableHead>
|
||||
<TableHead className='w-[120px]'>IP</TableHead>
|
||||
<TableHead className='w-[90px]'>状态</TableHead>
|
||||
<TableHead className='w-[100px]'>耗时</TableHead>
|
||||
<TableHead className='w-[170px]'>请求时间</TableHead>
|
||||
<TableHead className='w-[80px] text-center'>详情</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id} className='hover:bg-muted/30'>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='font-mono font-semibold uppercase text-xs'
|
||||
>
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs break-all max-w-[280px]'>
|
||||
{log.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-xs font-medium'>
|
||||
{log.username || '未知用户'}
|
||||
</span>
|
||||
{log.nickname && (
|
||||
<span className='text-[10px] text-muted-foreground'>
|
||||
({log.nickname})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs text-muted-foreground'>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(log.status)}>
|
||||
{log.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs text-muted-foreground'>
|
||||
{formatLatency(log.latency)}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-[11px] text-muted-foreground'>
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 hover:bg-muted'
|
||||
onClick={() => {
|
||||
setSelectedLog(log);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className='size-3.5' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {total} 条记录,当前第 {page}/{totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{logs.length > 0 && (
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
共 {total} 条记录,当前第 {page}/{totalPages} 页
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
<ChevronLeft className='size-3.5 mr-1' />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className='size-3.5 ml-1' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l">
|
||||
<SheetHeader className="border-b px-5 py-4 shrink-0">
|
||||
<SheetTitle className="text-lg font-bold">访问日志详情</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
访问 ID: {selectedLog?.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{/* Detail Drawer */}
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'>
|
||||
<SheetHeader className='border-b px-5 py-4 shrink-0'>
|
||||
<SheetTitle className='text-lg font-bold'>访问日志详情</SheetTitle>
|
||||
<SheetDescription className='text-xs'>
|
||||
访问 ID: {selectedLog?.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{selectedLog ? (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Summary Details */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">请求方法</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold uppercase">{selectedLog.method}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">响应状态</div>
|
||||
<div className="mt-1">
|
||||
<Badge variant={statusVariant(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">耗时</div>
|
||||
<div className="mt-1 font-mono text-sm">{formatLatency(selectedLog.latency)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">IP 地址</div>
|
||||
<div className="mt-1 font-mono text-sm">{selectedLog.ip}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">用户</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"}
|
||||
<span className="block font-mono text-[10px] text-muted-foreground mt-0.5">ID: {selectedLog.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog ? (
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-5'>
|
||||
{/* Summary Details */}
|
||||
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
请求方法
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm font-bold uppercase'>
|
||||
{selectedLog.method}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
响应状态
|
||||
</div>
|
||||
<div className='mt-1'>
|
||||
<Badge variant={statusVariant(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
耗时
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm'>
|
||||
{formatLatency(selectedLog.latency)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
IP 地址
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
用户
|
||||
</div>
|
||||
<div className='mt-1 text-sm font-medium'>
|
||||
{selectedLog.username
|
||||
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
|
||||
: '未知/游客'}
|
||||
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
|
||||
ID: {selectedLog.user_id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求路径</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
|
||||
{selectedLog.path}
|
||||
</div>
|
||||
</div>
|
||||
{/* Path */}
|
||||
<div className='grid gap-2'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
请求路径
|
||||
</Label>
|
||||
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
|
||||
{selectedLog.path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Time */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求时间</Label>
|
||||
<div className="font-mono text-xs">{formatDateTime(selectedLog.created_at)}</div>
|
||||
</div>
|
||||
{/* Request Time */}
|
||||
<div className='grid gap-2'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
请求时间
|
||||
</Label>
|
||||
<div className='font-mono text-xs'>
|
||||
{formatDateTime(selectedLog.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Agent */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">User-Agent</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(selectedLog.user_agent, "User-Agent")}
|
||||
title="复制 User-Agent"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed">
|
||||
{selectedLog.user_agent}
|
||||
</div>
|
||||
</div>
|
||||
{/* User Agent */}
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
User-Agent
|
||||
</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-6 text-muted-foreground hover:text-foreground'
|
||||
onClick={() =>
|
||||
copyToClipboard(selectedLog.user_agent, 'User-Agent')
|
||||
}
|
||||
title='复制 User-Agent'
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed'>
|
||||
{selectedLog.user_agent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="grid gap-2 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">Request Headers</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getPrettyHeaders(selectedLog.headers), "Headers")}
|
||||
title="复制 Headers"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]">
|
||||
{getPrettyHeaders(selectedLog.headers)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<EmptyStateWithBorder icon={Activity} description="未选中日志明细" />
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Headers */}
|
||||
<div className='grid gap-2 flex-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
Request Headers
|
||||
</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-6 text-muted-foreground hover:text-foreground'
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
getPrettyHeaders(selectedLog.headers),
|
||||
'Headers',
|
||||
)
|
||||
}
|
||||
title='复制 Headers'
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]'>
|
||||
{getPrettyHeaders(selectedLog.headers)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex-grow flex items-center justify-center'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
description='未选中日志明细'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,92 +14,103 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {memo, useCallback, useEffect, useRef, useState} from "react"
|
||||
import {useVirtualizer} from "@tanstack/react-virtual"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
|
||||
import {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useVirtualizer} from '@tanstack/react-virtual';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowDown, ChevronUp, Loader2} from 'lucide-react';
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import services from '@/lib/services';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Button} from '@/components/ui/button';
|
||||
|
||||
interface LogEntry {
|
||||
index: number
|
||||
data: string
|
||||
index: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
// Distance (px) from bottom to treat as "at bottom"
|
||||
const BOTTOM_THRESHOLD = 40
|
||||
const LOG_LINE_ESTIMATE_PX = 20
|
||||
const LOG_VIRTUAL_OVERSCAN = 24
|
||||
const BOTTOM_THRESHOLD = 40;
|
||||
const LOG_LINE_ESTIMATE_PX = 20;
|
||||
const LOG_VIRTUAL_OVERSCAN = 24;
|
||||
|
||||
const LogLine = memo(function LogLine({data}: {data: string}) {
|
||||
const level = parseLogLevel(data)
|
||||
const color = level === "error"
|
||||
? "text-red-400"
|
||||
: level === "warn"
|
||||
? "text-yellow-400"
|
||||
: level === "debug"
|
||||
? "text-gray-500"
|
||||
: "text-gray-300"
|
||||
const LogLine = memo(function LogLine({ data }: { data: string }) {
|
||||
const level = parseLogLevel(data);
|
||||
const color =
|
||||
level === 'error'
|
||||
? 'text-red-400'
|
||||
: level === 'warn'
|
||||
? 'text-yellow-400'
|
||||
: level === 'debug'
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-300';
|
||||
|
||||
return (
|
||||
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
|
||||
<div
|
||||
className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}
|
||||
>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window !== 'undefined') {
|
||||
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
|
||||
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
if (base.startsWith("http")) return base
|
||||
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
|
||||
if (base.startsWith('http')) return base;
|
||||
|
||||
// Relative URL fallback
|
||||
const proto = window.location.protocol
|
||||
const host = window.location.host
|
||||
return `${proto}//${host}${base}`
|
||||
const proto = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
return `${proto}//${host}${base}`;
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
|
||||
}
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const base = getApiBaseUrl()
|
||||
const wsBase = base.replace(/^http/, "ws")
|
||||
return `${wsBase}/api/v1/admin/logs/ws`
|
||||
const base = getApiBaseUrl();
|
||||
const wsBase = base.replace(/^http/, 'ws');
|
||||
return `${wsBase}/api/v1/admin/logs/ws`;
|
||||
}
|
||||
|
||||
function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" {
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error"
|
||||
if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn"
|
||||
if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug"
|
||||
if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info"
|
||||
return "unknown"
|
||||
function parseLogLevel(
|
||||
line: string,
|
||||
): 'debug' | 'info' | 'warn' | 'error' | 'unknown' {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('"level":"error"') || lower.includes('level=error'))
|
||||
return 'error';
|
||||
if (lower.includes('"level":"warn"') || lower.includes('level=warn'))
|
||||
return 'warn';
|
||||
if (lower.includes('"level":"debug"') || lower.includes('level=debug'))
|
||||
return 'debug';
|
||||
if (lower.includes('"level":"info"') || lower.includes('level=info'))
|
||||
return 'info';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function AppLogs() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState(0);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// autoScroll = true → new logs auto-scroll to bottom
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const autoScrollRef = useRef(autoScroll)
|
||||
const isUserScrolling = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const autoScrollRef = useRef(autoScroll);
|
||||
const isUserScrolling = useRef(false);
|
||||
|
||||
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
|
||||
useEffect(() => {
|
||||
autoScrollRef.current = autoScroll;
|
||||
}, [autoScroll]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
@@ -107,200 +118,222 @@ export function AppLogs() {
|
||||
estimateSize: () => LOG_LINE_ESTIMATE_PX,
|
||||
overscan: LOG_VIRTUAL_OVERSCAN,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
})
|
||||
});
|
||||
|
||||
// ---- Scroll detection ------------------------------------------------
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!isUserScrolling.current) return
|
||||
if (!isUserScrolling.current) return;
|
||||
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||
const atBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD;
|
||||
if (atBottom && !autoScrollRef.current) {
|
||||
setAutoScroll(true)
|
||||
setAutoScroll(true);
|
||||
} else if (!atBottom && autoScrollRef.current) {
|
||||
setAutoScroll(false)
|
||||
setAutoScroll(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
const handleWheel = useCallback(() => {
|
||||
isUserScrolling.current = true;
|
||||
}, []);
|
||||
const handleTouchStart = useCallback(() => {
|
||||
isUserScrolling.current = true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScrollEnd = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => { isUserScrolling.current = false }, 150)
|
||||
}
|
||||
el.addEventListener("scroll", onScrollEnd, { passive: true })
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
isUserScrolling.current = false;
|
||||
}, 150);
|
||||
};
|
||||
el.addEventListener('scroll', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScrollEnd)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
el.removeEventListener('scroll', onScrollEnd);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- Auto-scroll to bottom when new logs arrive ----------------------
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !containerRef.current) return
|
||||
isUserScrolling.current = false
|
||||
const el = containerRef.current
|
||||
if (!autoScroll || !containerRef.current) return;
|
||||
isUserScrolling.current = false;
|
||||
const el = containerRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}, [logs, autoScroll])
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// ---- Data fetching ---------------------------------------------------
|
||||
const fetchLogs = useCallback(async (cursor: number = 0) => {
|
||||
try {
|
||||
return await services.adminLog.getLogs(cursor)
|
||||
return await services.adminLog.getLogs(cursor);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error("获取日志失败")
|
||||
throw err instanceof Error ? err : new Error('获取日志失败');
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async (cursor: number = 0) => {
|
||||
const isInitial = cursor === 0
|
||||
if (isInitial) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchLogs(cursor)
|
||||
const loadHistory = useCallback(
|
||||
async (cursor: number = 0) => {
|
||||
const isInitial = cursor === 0;
|
||||
if (isInitial) {
|
||||
setLogs(data.lines || [])
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
} else {
|
||||
const el = containerRef.current
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
setLogs(prev => [...(data.lines || []), ...prev])
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return
|
||||
isUserScrolling.current = false
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight
|
||||
})
|
||||
try {
|
||||
const data = await fetchLogs(cursor);
|
||||
if (isInitial) {
|
||||
setLogs(data.lines || []);
|
||||
} else {
|
||||
const el = containerRef.current;
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0;
|
||||
setLogs((prev) => [...(data.lines || []), ...prev]);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
isUserScrolling.current = false;
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight;
|
||||
});
|
||||
}
|
||||
setHasMore(data.has_more);
|
||||
setNextCursor(data.next_cursor);
|
||||
} catch (err) {
|
||||
if (isInitial) {
|
||||
setError(err instanceof Error ? err : new Error('获取日志失败'));
|
||||
} else {
|
||||
toast.error('加载更早日志失败');
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) setLoading(false);
|
||||
else setLoadingMore(false);
|
||||
}
|
||||
setHasMore(data.has_more)
|
||||
setNextCursor(data.next_cursor)
|
||||
} catch (err) {
|
||||
if (isInitial) {
|
||||
setError(err instanceof Error ? err : new Error("获取日志失败"))
|
||||
} else {
|
||||
toast.error("加载更早日志失败")
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) setLoading(false)
|
||||
else setLoadingMore(false)
|
||||
}
|
||||
}, [fetchLogs])
|
||||
},
|
||||
[fetchLogs],
|
||||
);
|
||||
|
||||
// ---- WebSocket -------------------------------------------------------
|
||||
const connectWs = useCallback(() => {
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
|
||||
const ws = new WebSocket(buildWsUrl())
|
||||
wsRef.current = ws
|
||||
const ws = new WebSocket(buildWsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "log" && msg.data) {
|
||||
const entry: LogEntry = msg.data
|
||||
setLogs(prev => {
|
||||
const next = [...prev, entry]
|
||||
return next.length > 2000 ? next.slice(-2000) : next
|
||||
})
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'log' && msg.data) {
|
||||
const entry: LogEntry = msg.data;
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > 2000 ? next.slice(-2000) : next;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => { wsRef.current = null }
|
||||
}, [])
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- Initialize ------------------------------------------------------
|
||||
useEffect(() => {
|
||||
loadHistory(0).then(() => connectWs())
|
||||
loadHistory(0).then(() => connectWs());
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// ---- Actions ---------------------------------------------------------
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setAutoScroll(true)
|
||||
setAutoScroll(true);
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (nextCursor > 0) loadHistory(nextCursor)
|
||||
}, [nextCursor, loadHistory])
|
||||
if (nextCursor > 0) loadHistory(nextCursor);
|
||||
}, [nextCursor, loadHistory]);
|
||||
|
||||
// ---- Render ----------------------------------------------------------
|
||||
if (loading) return <LoadingStateWithBorder />
|
||||
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
|
||||
if (loading) return <LoadingStateWithBorder />;
|
||||
if (error)
|
||||
return <ErrorInline error={error} onRetry={() => loadHistory(0)} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<div className='flex flex-col h-full relative'>
|
||||
{/* Log viewer — fixed height, scrollable */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
|
||||
className='h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative'
|
||||
>
|
||||
{/* Load older logs */}
|
||||
{hasMore && (
|
||||
<div className="sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm">
|
||||
<div className='sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="text-muted-foreground hover:text-foreground h-7 text-xs"
|
||||
className='text-muted-foreground hover:text-foreground h-7 text-xs'
|
||||
>
|
||||
{loadingMore
|
||||
? <><Loader2 className="size-3 mr-1.5 animate-spin" />加载中...</>
|
||||
: <><ChevronUp className="size-3 mr-1.5" />加载更早日志</>
|
||||
}
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className='size-3 mr-1.5 animate-spin' />
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className='size-3 mr-1.5' />
|
||||
加载更早日志
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">暂无日志</div>
|
||||
<div className='px-4 py-8 text-center text-gray-500'>暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-3"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
className='relative px-3 py-3'
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = logs[virtualRow.index]
|
||||
if (!entry) return null
|
||||
const entry = logs[virtualRow.index];
|
||||
if (!entry) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{transform: `translateY(${virtualRow.start}px)`}}
|
||||
className='absolute top-0 left-0 w-full'
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
<LogLine data={entry.data} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -308,18 +341,18 @@ export function AppLogs() {
|
||||
|
||||
{/* Floating "back to latest" button */}
|
||||
{!autoScroll && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className='absolute bottom-6 left-1/2 -translate-x-1/2 z-20'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={scrollToBottom}
|
||||
className="shadow-lg bg-background/80 backdrop-blur-sm border-border/50"
|
||||
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
|
||||
>
|
||||
<ArrowDown className="size-3.5 mr-1.5" />
|
||||
<ArrowDown className='size-3.5 mr-1.5' />
|
||||
回到最新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,76 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {Activity, BarChart3, Terminal} from "lucide-react"
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Activity, BarChart3, Terminal } from 'lucide-react';
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
const tabFallback = (
|
||||
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
<div className='h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20' />
|
||||
);
|
||||
|
||||
const AccessAnalytics = dynamic(
|
||||
() => import("./components/access-analytics").then((mod) => mod.AccessAnalytics),
|
||||
() =>
|
||||
import('./components/access-analytics').then((mod) => mod.AccessAnalytics),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const AccessLogs = dynamic(
|
||||
() => import("./components/access-logs").then((mod) => mod.AccessLogs),
|
||||
() => import('./components/access-logs').then((mod) => mod.AccessLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const AppLogs = dynamic(
|
||||
() => import("./components/app-logs").then((mod) => mod.AppLogs),
|
||||
() => import('./components/app-logs').then((mod) => mod.AppLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
export function LogsPageClient() {
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6 py-6">
|
||||
<div className='flex flex-col h-full space-y-6 py-6'>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Terminal className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>系统日志</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs Layout */}
|
||||
<Tabs defaultValue="analytics" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="analytics" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
<Tabs defaultValue='analytics' className='w-full'>
|
||||
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
|
||||
<TabsTrigger
|
||||
value='analytics'
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<BarChart3 className='size-3.5' />
|
||||
访问分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
<TabsTrigger
|
||||
value='access'
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<Activity className='size-3.5' />
|
||||
用户访问日志
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
<TabsTrigger
|
||||
value='app'
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<Terminal className='size-3.5' />
|
||||
应用运行日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analytics" className="mt-0 outline-none flex-1">
|
||||
<TabsContent value='analytics' className='mt-0 outline-none flex-1'>
|
||||
<AccessAnalytics />
|
||||
</TabsContent>
|
||||
<TabsContent value="access" className="mt-0 outline-none flex-1">
|
||||
<TabsContent value='access' className='mt-0 outline-none flex-1'>
|
||||
<AccessLogs />
|
||||
</TabsContent>
|
||||
<TabsContent value="app" className="mt-0 outline-none flex-1">
|
||||
<TabsContent value='app' className='mt-0 outline-none flex-1'>
|
||||
<AppLogs />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {LogsPageClient} from "./page-client"
|
||||
import { LogsPageClient } from './page-client';
|
||||
|
||||
export default function LogsPage() {
|
||||
return <LogsPageClient />
|
||||
}
|
||||
return <LogsPageClient />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,40 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
|
||||
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 { 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 { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,156 +43,205 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import type {PushHistory} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
import type { PushHistory } from '@/lib/services/push';
|
||||
import { PushService } from '@/lib/services/push';
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
function getLevelBadgeVariant(
|
||||
level: string,
|
||||
): 'outline' | 'secondary' | 'destructive' | 'default' {
|
||||
switch (level) {
|
||||
case "CRITICAL":
|
||||
return "destructive"
|
||||
case "IMPORTANT":
|
||||
return "default"
|
||||
case 'CRITICAL':
|
||||
return 'destructive';
|
||||
case 'IMPORTANT':
|
||||
return 'default';
|
||||
default:
|
||||
return "outline"
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
const [historyPage, setHistoryPage] = React.useState(1)
|
||||
const [historySearch, setHistorySearch] = React.useState("")
|
||||
const [historyStatus, setHistoryStatus] = React.useState("all")
|
||||
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 [detailOpen, setDetailOpen] = React.useState(false);
|
||||
const [selectedHistory, setSelectedHistory] =
|
||||
React.useState<PushHistory | null>(null);
|
||||
|
||||
const handleRowClick = (hist: PushHistory) => {
|
||||
setSelectedHistory(hist)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
setSelectedHistory(hist);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryKey: [
|
||||
'admin',
|
||||
'push-histories',
|
||||
historyPage,
|
||||
historySearch,
|
||||
historyStatus,
|
||||
],
|
||||
queryFn: () =>
|
||||
PushService.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
status: historyStatus === "all" ? undefined : historyStatus,
|
||||
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" />
|
||||
<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"
|
||||
type='text'
|
||||
placeholder='输入事件键过滤...'
|
||||
className='pl-8 text-xs h-9'
|
||||
value={historySearch}
|
||||
onChange={e => {
|
||||
setHistorySearch(e.target.value)
|
||||
setHistoryPage(1)
|
||||
onChange={(e) => {
|
||||
setHistorySearch(e.target.value);
|
||||
setHistoryPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<div className='w-[150px]'>
|
||||
<Select
|
||||
value={historyStatus}
|
||||
onValueChange={val => {
|
||||
setHistoryStatus(val)
|
||||
setHistoryPage(1)
|
||||
onValueChange={(val) => {
|
||||
setHistoryStatus(val);
|
||||
setHistoryPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="发送状态" />
|
||||
<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>
|
||||
<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"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-9 w-9 shrink-0'
|
||||
onClick={() => historiesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-3.5',
|
||||
historiesQuery.isFetching && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<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 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 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 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
|
||||
colSpan={7}
|
||||
className='h-32 text-center text-xs text-muted-foreground'
|
||||
>
|
||||
无推送历史数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(historiesQuery.data?.results ?? []).map(hist => (
|
||||
(historiesQuery.data?.results ?? []).map((hist) => (
|
||||
<TableRow
|
||||
key={hist.id}
|
||||
className="hover:bg-muted/10 cursor-pointer transition-colors"
|
||||
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 className='text-xs font-mono font-medium'>
|
||||
{hist.event_key}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
|
||||
<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">
|
||||
<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">
|
||||
<TableCell className='text-xs'>
|
||||
<Badge
|
||||
variant={hist.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold"
|
||||
variant={
|
||||
hist.status === 'success' ? 'secondary' : 'destructive'
|
||||
}
|
||||
className='text-[10px] font-semibold'
|
||||
>
|
||||
{hist.status === "success" ? "成功" : "失败"}
|
||||
{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.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">
|
||||
<TableCell className='text-[11px] text-muted-foreground whitespace-nowrap'>
|
||||
{new Date(hist.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -184,27 +252,27 @@ export function HistoriesTab() {
|
||||
</div>
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<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">
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
|
||||
className="h-8 w-8"
|
||||
onClick={() => setHistoryPage((p) => Math.max(1, p - 1))}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<ChevronLeft className='size-4' />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{historyPage}</span>
|
||||
<span className='text-xs font-medium px-2'>{historyPage}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
disabled={historyPage * 10 >= historiesQuery.data.total}
|
||||
onClick={() => setHistoryPage(p => p + 1)}
|
||||
className="h-8 w-8"
|
||||
onClick={() => setHistoryPage((p) => p + 1)}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,10 +280,10 @@ export function HistoriesTab() {
|
||||
|
||||
{/* ==================== 对话框:推送详情 ==================== */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<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 className='flex items-center gap-2'>
|
||||
<History className='size-5 text-primary' />
|
||||
推送通知详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -224,76 +292,109 @@ export function HistoriesTab() {
|
||||
</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 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 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}>
|
||||
<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">
|
||||
<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 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">
|
||||
<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 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"
|
||||
variant={
|
||||
selectedHistory.status === 'success'
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
className='text-[10px] font-semibold py-0.5 px-2'
|
||||
>
|
||||
{selectedHistory.status === "success" ? "成功" : "失败"}
|
||||
{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}
|
||||
{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>
|
||||
)}
|
||||
)}
|
||||
|
||||
<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]">
|
||||
<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">
|
||||
<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>
|
||||
@@ -301,12 +402,17 @@ export function HistoriesTab() {
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setDetailOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,76 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {Bell, History, Layers, Settings} from "lucide-react"
|
||||
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"
|
||||
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")
|
||||
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"
|
||||
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 className='flex items-center gap-2'>
|
||||
<Bell className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
|
||||
<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" />
|
||||
<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
|
||||
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
|
||||
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">
|
||||
<TabsContent value='events' className='focus-visible:outline-none'>
|
||||
<EventsTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 2. 通知历史 TAB ==================== */}
|
||||
<TabsContent value="histories" className="focus-visible:outline-none">
|
||||
<TabsContent value='histories' className='focus-visible:outline-none'>
|
||||
<HistoriesTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
|
||||
<TabsContent value="settings" className="focus-visible:outline-none">
|
||||
<TabsContent value='settings' className='focus-visible:outline-none'>
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,183 +1,228 @@
|
||||
import type {OptionItem} from "@/lib/services/openflare"
|
||||
import type { OptionItem } from '@/lib/services/openflare';
|
||||
|
||||
export type OpenFlareOpsFields = {
|
||||
agent_heartbeat_interval: string
|
||||
agent_websocket_upgrade_enabled: boolean
|
||||
node_offline_threshold: string
|
||||
agent_update_repo: string
|
||||
geoip_provider: string
|
||||
server_address: string
|
||||
uptime_kuma_enabled: boolean
|
||||
uptime_kuma_url: string
|
||||
uptime_kuma_username: string
|
||||
uptime_kuma_password: string
|
||||
uptime_kuma_monitor_scope: string
|
||||
uptime_kuma_selected_sites: string
|
||||
uptime_kuma_sync_interval: string
|
||||
uptime_kuma_interval: string
|
||||
uptime_kuma_retry: string
|
||||
uptime_kuma_retry_interval: string
|
||||
uptime_kuma_timeout: string
|
||||
database_auto_cleanup_enabled: boolean
|
||||
database_auto_cleanup_retention_days: string
|
||||
}
|
||||
agent_heartbeat_interval: string;
|
||||
agent_websocket_upgrade_enabled: boolean;
|
||||
node_offline_threshold: string;
|
||||
agent_update_repo: string;
|
||||
geoip_provider: string;
|
||||
server_address: string;
|
||||
uptime_kuma_enabled: boolean;
|
||||
uptime_kuma_url: string;
|
||||
uptime_kuma_username: string;
|
||||
uptime_kuma_password: string;
|
||||
uptime_kuma_monitor_scope: string;
|
||||
uptime_kuma_selected_sites: string;
|
||||
uptime_kuma_sync_interval: string;
|
||||
uptime_kuma_interval: string;
|
||||
uptime_kuma_retry: string;
|
||||
uptime_kuma_retry_interval: string;
|
||||
uptime_kuma_timeout: string;
|
||||
database_auto_cleanup_enabled: boolean;
|
||||
database_auto_cleanup_retention_days: string;
|
||||
};
|
||||
|
||||
export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
|
||||
agent_heartbeat_interval: "10000",
|
||||
agent_heartbeat_interval: '10000',
|
||||
agent_websocket_upgrade_enabled: true,
|
||||
node_offline_threshold: "120000",
|
||||
agent_update_repo: "Rain-kl/OpenFlare",
|
||||
geoip_provider: "ipinfo",
|
||||
server_address: "",
|
||||
node_offline_threshold: '120000',
|
||||
agent_update_repo: 'Rain-kl/OpenFlare',
|
||||
geoip_provider: 'ipinfo',
|
||||
server_address: '',
|
||||
uptime_kuma_enabled: false,
|
||||
uptime_kuma_url: "",
|
||||
uptime_kuma_username: "",
|
||||
uptime_kuma_password: "",
|
||||
uptime_kuma_monitor_scope: "all",
|
||||
uptime_kuma_selected_sites: "",
|
||||
uptime_kuma_sync_interval: "5",
|
||||
uptime_kuma_interval: "60",
|
||||
uptime_kuma_retry: "0",
|
||||
uptime_kuma_retry_interval: "60",
|
||||
uptime_kuma_timeout: "48",
|
||||
uptime_kuma_url: '',
|
||||
uptime_kuma_username: '',
|
||||
uptime_kuma_password: '',
|
||||
uptime_kuma_monitor_scope: 'all',
|
||||
uptime_kuma_selected_sites: '',
|
||||
uptime_kuma_sync_interval: '5',
|
||||
uptime_kuma_interval: '60',
|
||||
uptime_kuma_retry: '0',
|
||||
uptime_kuma_retry_interval: '60',
|
||||
uptime_kuma_timeout: '48',
|
||||
database_auto_cleanup_enabled: false,
|
||||
database_auto_cleanup_retention_days: "30",
|
||||
}
|
||||
database_auto_cleanup_retention_days: '30',
|
||||
};
|
||||
|
||||
export const INSTALLER_SCRIPT_URL =
|
||||
"https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh"
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function optionsToMap(options: OptionItem[]) {
|
||||
return options.reduce<Record<string, string>>((accumulator, option) => {
|
||||
accumulator[option.key] = option.value
|
||||
return accumulator
|
||||
}, {})
|
||||
accumulator[option.key] = option.value;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function toBoolean(value: string | undefined, fallback: boolean) {
|
||||
if (value === undefined) return fallback
|
||||
return value === "true"
|
||||
if (value === undefined) return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export function mapOptionsToOpsFields(
|
||||
optionMap: Record<string, string>,
|
||||
serverAddress = "",
|
||||
serverAddress = '',
|
||||
): OpenFlareOpsFields {
|
||||
return {
|
||||
agent_heartbeat_interval: optionMap.agent_heartbeat_interval ?? "10000",
|
||||
agent_websocket_upgrade_enabled: toBoolean(optionMap.agent_websocket_upgrade_enabled, true),
|
||||
node_offline_threshold: optionMap.node_offline_threshold ?? "120000",
|
||||
agent_update_repo: optionMap.agent_update_repo ?? "Rain-kl/OpenFlare",
|
||||
geoip_provider: optionMap.geoip_provider ?? "ipinfo",
|
||||
agent_heartbeat_interval: optionMap.agent_heartbeat_interval ?? '10000',
|
||||
agent_websocket_upgrade_enabled: toBoolean(
|
||||
optionMap.agent_websocket_upgrade_enabled,
|
||||
true,
|
||||
),
|
||||
node_offline_threshold: optionMap.node_offline_threshold ?? '120000',
|
||||
agent_update_repo: optionMap.agent_update_repo ?? 'Rain-kl/OpenFlare',
|
||||
geoip_provider: optionMap.geoip_provider ?? 'ipinfo',
|
||||
server_address: optionMap.server_address || serverAddress,
|
||||
uptime_kuma_enabled: toBoolean(optionMap.uptime_kuma_enabled, false),
|
||||
uptime_kuma_url: optionMap.uptime_kuma_url ?? "",
|
||||
uptime_kuma_username: optionMap.uptime_kuma_username ?? "",
|
||||
uptime_kuma_password: "",
|
||||
uptime_kuma_monitor_scope: optionMap.uptime_kuma_monitor_scope ?? "all",
|
||||
uptime_kuma_selected_sites: optionMap.uptime_kuma_selected_sites ?? "",
|
||||
uptime_kuma_sync_interval: optionMap.uptime_kuma_sync_interval ?? "5",
|
||||
uptime_kuma_interval: optionMap.uptime_kuma_interval ?? "60",
|
||||
uptime_kuma_retry: optionMap.uptime_kuma_retry ?? "0",
|
||||
uptime_kuma_retry_interval: optionMap.uptime_kuma_retry_interval ?? "60",
|
||||
uptime_kuma_timeout: optionMap.uptime_kuma_timeout ?? "48",
|
||||
database_auto_cleanup_enabled: toBoolean(optionMap.database_auto_cleanup_enabled, false),
|
||||
database_auto_cleanup_retention_days: optionMap.database_auto_cleanup_retention_days ?? "30",
|
||||
}
|
||||
uptime_kuma_url: optionMap.uptime_kuma_url ?? '',
|
||||
uptime_kuma_username: optionMap.uptime_kuma_username ?? '',
|
||||
uptime_kuma_password: '',
|
||||
uptime_kuma_monitor_scope: optionMap.uptime_kuma_monitor_scope ?? 'all',
|
||||
uptime_kuma_selected_sites: optionMap.uptime_kuma_selected_sites ?? '',
|
||||
uptime_kuma_sync_interval: optionMap.uptime_kuma_sync_interval ?? '5',
|
||||
uptime_kuma_interval: optionMap.uptime_kuma_interval ?? '60',
|
||||
uptime_kuma_retry: optionMap.uptime_kuma_retry ?? '0',
|
||||
uptime_kuma_retry_interval: optionMap.uptime_kuma_retry_interval ?? '60',
|
||||
uptime_kuma_timeout: optionMap.uptime_kuma_timeout ?? '48',
|
||||
database_auto_cleanup_enabled: toBoolean(
|
||||
optionMap.database_auto_cleanup_enabled,
|
||||
false,
|
||||
),
|
||||
database_auto_cleanup_retention_days:
|
||||
optionMap.database_auto_cleanup_retention_days ?? '30',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDurationLabel(value: string) {
|
||||
const milliseconds = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(milliseconds)) return value
|
||||
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`
|
||||
return `${milliseconds / 1000} 秒`
|
||||
const milliseconds = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(milliseconds)) return value;
|
||||
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`;
|
||||
return `${milliseconds / 1000} 秒`;
|
||||
}
|
||||
|
||||
export function normalizeServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, "")
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function getBrowserOrigin() {
|
||||
if (typeof window === "undefined") return ""
|
||||
return normalizeServerUrl(window.location.origin)
|
||||
if (typeof window === 'undefined') return '';
|
||||
return normalizeServerUrl(window.location.origin);
|
||||
}
|
||||
|
||||
export function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) {
|
||||
export function buildDiscoveryCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
) {
|
||||
return [
|
||||
`curl -fsSL ${INSTALLER_SCRIPT_URL} | bash -s -- \\`,
|
||||
` --server-url ${normalizeServerUrl(serverUrl)} \\`,
|
||||
` --discovery-token ${discoveryToken}`,
|
||||
].join("\n")
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function validateAgentFields(fields: OpenFlareOpsFields) {
|
||||
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10)
|
||||
const offline = Number.parseInt(fields.node_offline_threshold, 10)
|
||||
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
|
||||
const offline = Number.parseInt(fields.node_offline_threshold, 10);
|
||||
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
|
||||
throw new Error("心跳间隔不能小于 5000 毫秒。")
|
||||
throw new Error('心跳间隔不能小于 5000 毫秒。');
|
||||
}
|
||||
if (Number.isNaN(offline) || offline < 10000) {
|
||||
throw new Error("离线阈值不能小于 10000 毫秒。")
|
||||
throw new Error('离线阈值不能小于 10000 毫秒。');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
|
||||
const syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10)
|
||||
const interval = Number.parseInt(fields.uptime_kuma_interval, 10)
|
||||
const retry = Number.parseInt(fields.uptime_kuma_retry, 10)
|
||||
const retryInt = Number.parseInt(fields.uptime_kuma_retry_interval, 10)
|
||||
const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10)
|
||||
const syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10);
|
||||
const interval = Number.parseInt(fields.uptime_kuma_interval, 10);
|
||||
const retry = Number.parseInt(fields.uptime_kuma_retry, 10);
|
||||
const retryInt = Number.parseInt(fields.uptime_kuma_retry_interval, 10);
|
||||
const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10);
|
||||
|
||||
if (fields.uptime_kuma_enabled) {
|
||||
if (!fields.uptime_kuma_url.trim()) throw new Error("请输入 Uptime Kuma 地址。")
|
||||
if (!fields.uptime_kuma_username.trim()) throw new Error("请输入 Uptime Kuma 用户名。")
|
||||
if (!fields.uptime_kuma_url.trim())
|
||||
throw new Error('请输入 Uptime Kuma 地址。');
|
||||
if (!fields.uptime_kuma_username.trim())
|
||||
throw new Error('请输入 Uptime Kuma 用户名。');
|
||||
}
|
||||
if (Number.isNaN(syncInt) || syncInt <= 0) throw new Error("同步间隔必须为正整数。")
|
||||
if (Number.isNaN(interval) || interval <= 0) throw new Error("心跳间隔必须为正整数。")
|
||||
if (Number.isNaN(retry) || retry < 0) throw new Error("重试次数必须为非负整数。")
|
||||
if (Number.isNaN(retryInt) || retryInt <= 0) throw new Error("心跳重试间隔必须为正整数。")
|
||||
if (Number.isNaN(timeout) || timeout <= 0) throw new Error("请求超时必须为正整数。")
|
||||
if (Number.isNaN(syncInt) || syncInt <= 0)
|
||||
throw new Error('同步间隔必须为正整数。');
|
||||
if (Number.isNaN(interval) || interval <= 0)
|
||||
throw new Error('心跳间隔必须为正整数。');
|
||||
if (Number.isNaN(retry) || retry < 0)
|
||||
throw new Error('重试次数必须为非负整数。');
|
||||
if (Number.isNaN(retryInt) || retryInt <= 0)
|
||||
throw new Error('心跳重试间隔必须为正整数。');
|
||||
if (Number.isNaN(timeout) || timeout <= 0)
|
||||
throw new Error('请求超时必须为正整数。');
|
||||
}
|
||||
|
||||
export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) {
|
||||
const retentionDays = Number.parseInt(fields.database_auto_cleanup_retention_days, 10)
|
||||
const retentionDays = Number.parseInt(
|
||||
fields.database_auto_cleanup_retention_days,
|
||||
10,
|
||||
);
|
||||
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||
throw new Error("自动清理保留天数至少为 1 天。")
|
||||
throw new Error('自动清理保留天数至少为 1 天。');
|
||||
}
|
||||
}
|
||||
|
||||
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateAgentFields(fields)
|
||||
validateAgentFields(fields);
|
||||
return [
|
||||
{ key: "agent_heartbeat_interval", value: fields.agent_heartbeat_interval },
|
||||
{ key: "agent_websocket_upgrade_enabled", value: String(fields.agent_websocket_upgrade_enabled) },
|
||||
{ key: "node_offline_threshold", value: fields.node_offline_threshold },
|
||||
{ key: "agent_update_repo", value: fields.agent_update_repo.trim() },
|
||||
{ key: "geoip_provider", value: fields.geoip_provider },
|
||||
]
|
||||
{ key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval },
|
||||
{
|
||||
key: 'agent_websocket_upgrade_enabled',
|
||||
value: String(fields.agent_websocket_upgrade_enabled),
|
||||
},
|
||||
{ key: 'node_offline_threshold', value: fields.node_offline_threshold },
|
||||
{ key: 'agent_update_repo', value: fields.agent_update_repo.trim() },
|
||||
{ key: 'geoip_provider', value: fields.geoip_provider },
|
||||
];
|
||||
}
|
||||
|
||||
export function uptimeKumaOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateUptimeKumaFields(fields)
|
||||
export function uptimeKumaOptionEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
): OptionItem[] {
|
||||
validateUptimeKumaFields(fields);
|
||||
return [
|
||||
{ key: "uptime_kuma_enabled", value: String(fields.uptime_kuma_enabled) },
|
||||
{ key: "uptime_kuma_url", value: fields.uptime_kuma_url.trim() },
|
||||
{ key: "uptime_kuma_username", value: fields.uptime_kuma_username.trim() },
|
||||
{ key: "uptime_kuma_password", value: fields.uptime_kuma_password },
|
||||
{ key: "uptime_kuma_monitor_scope", value: fields.uptime_kuma_monitor_scope },
|
||||
{ key: "uptime_kuma_selected_sites", value: fields.uptime_kuma_selected_sites },
|
||||
{ key: "uptime_kuma_sync_interval", value: fields.uptime_kuma_sync_interval },
|
||||
{ key: "uptime_kuma_interval", value: fields.uptime_kuma_interval },
|
||||
{ key: "uptime_kuma_retry", value: fields.uptime_kuma_retry },
|
||||
{ key: "uptime_kuma_retry_interval", value: fields.uptime_kuma_retry_interval },
|
||||
{ key: "uptime_kuma_timeout", value: fields.uptime_kuma_timeout },
|
||||
]
|
||||
{ key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) },
|
||||
{ key: 'uptime_kuma_url', value: fields.uptime_kuma_url.trim() },
|
||||
{ key: 'uptime_kuma_username', value: fields.uptime_kuma_username.trim() },
|
||||
{ key: 'uptime_kuma_password', value: fields.uptime_kuma_password },
|
||||
{
|
||||
key: 'uptime_kuma_monitor_scope',
|
||||
value: fields.uptime_kuma_monitor_scope,
|
||||
},
|
||||
{
|
||||
key: 'uptime_kuma_selected_sites',
|
||||
value: fields.uptime_kuma_selected_sites,
|
||||
},
|
||||
{
|
||||
key: 'uptime_kuma_sync_interval',
|
||||
value: fields.uptime_kuma_sync_interval,
|
||||
},
|
||||
{ key: 'uptime_kuma_interval', value: fields.uptime_kuma_interval },
|
||||
{ key: 'uptime_kuma_retry', value: fields.uptime_kuma_retry },
|
||||
{
|
||||
key: 'uptime_kuma_retry_interval',
|
||||
value: fields.uptime_kuma_retry_interval,
|
||||
},
|
||||
{ key: 'uptime_kuma_timeout', value: fields.uptime_kuma_timeout },
|
||||
];
|
||||
}
|
||||
|
||||
export function databaseAutoCleanupEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateDatabaseAutoCleanup(fields)
|
||||
export function databaseAutoCleanupEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
): OptionItem[] {
|
||||
validateDatabaseAutoCleanup(fields);
|
||||
return [
|
||||
{ key: "database_auto_cleanup_enabled", value: String(fields.database_auto_cleanup_enabled) },
|
||||
{ key: "database_auto_cleanup_retention_days", value: fields.database_auto_cleanup_retention_days },
|
||||
]
|
||||
{
|
||||
key: 'database_auto_cleanup_enabled',
|
||||
value: String(fields.database_auto_cleanup_enabled),
|
||||
},
|
||||
{
|
||||
key: 'database_auto_cleanup_retention_days',
|
||||
value: fields.database_auto_cleanup_retention_days,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,206 +1,242 @@
|
||||
"use client"
|
||||
'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"
|
||||
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, ",")
|
||||
}
|
||||
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 [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 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)
|
||||
if (!isSilent) setWavelet(true);
|
||||
try {
|
||||
const data = await services.adminStatus.getSystemStatus()
|
||||
const data = await services.adminStatus.getSystemStatus();
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
const changes: Record<string, boolean> = {}
|
||||
const changes: Record<string, boolean> = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
const k = key as keyof SystemStatus
|
||||
const k = key as keyof SystemStatus;
|
||||
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
|
||||
changes[k] = true
|
||||
changes[k] = true;
|
||||
}
|
||||
})
|
||||
setChangedFields(changes)
|
||||
});
|
||||
setChangedFields(changes);
|
||||
// 1秒后清除变化状态
|
||||
setTimeout(() => {
|
||||
setChangedFields({})
|
||||
}, 1000)
|
||||
setChangedFields({});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
prevStatusRef.current = data
|
||||
setStatus(data);
|
||||
prevStatusRef.current = data;
|
||||
} catch (err) {
|
||||
toast.error("获取系统状态失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误"
|
||||
})
|
||||
toast.error('获取系统状态失败', {
|
||||
description: err instanceof Error ? err.message : '请求时发生未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setWavelet(false)
|
||||
setLoading(false);
|
||||
setWavelet(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
// 首次加载
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const time = parseInt(intervalTime, 10)
|
||||
const time = parseInt(intervalTime, 10);
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchStatus(true)
|
||||
}, time)
|
||||
fetchStatus(true);
|
||||
}, time);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, intervalTime, fetchStatus])
|
||||
};
|
||||
}, [autoRefresh, intervalTime, fetchStatus]);
|
||||
|
||||
// 计算内存分配百分比 (Alloc / Sys)
|
||||
const getMemoryUsagePercent = () => {
|
||||
if (!status) return 0
|
||||
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()
|
||||
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
|
||||
}
|
||||
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 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"
|
||||
}
|
||||
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}
|
||||
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 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 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 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='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 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>
|
||||
<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">
|
||||
<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"
|
||||
id='auto-refresh'
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
|
||||
<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">
|
||||
<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>
|
||||
<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"
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='h-7 text-[11px] gap-1.5'
|
||||
onClick={() => fetchStatus()}
|
||||
disabled={wavelet}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
|
||||
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
@@ -209,130 +245,277 @@ export function SystemStatusManager() {
|
||||
{status && (
|
||||
<>
|
||||
{/* 主网格排版 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
<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>
|
||||
<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
|
||||
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 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>
|
||||
<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
|
||||
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" />
|
||||
<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">
|
||||
<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>
|
||||
<span className='font-mono'>
|
||||
{getMemoryUsagePercent()}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
|
||||
<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>
|
||||
<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
|
||||
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 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>
|
||||
<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
|
||||
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 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>
|
||||
<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
|
||||
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 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" />
|
||||
<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" />
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,17 +11,17 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {ProxyRouteService} from "@/lib/services/openflare"
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ProxyRouteService } from '@/lib/services/openflare';
|
||||
|
||||
type UptimeKumaSiteSelectModalProps = {
|
||||
open: boolean
|
||||
selectedSites: string[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (sites: string[]) => void
|
||||
}
|
||||
open: boolean;
|
||||
selectedSites: string[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (sites: string[]) => void;
|
||||
};
|
||||
|
||||
export function UptimeKumaSiteSelectModal({
|
||||
open,
|
||||
@@ -29,62 +29,66 @@ export function UptimeKumaSiteSelectModal({
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: UptimeKumaSiteSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set())
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ["openflare", "proxy-routes"],
|
||||
queryKey: ['openflare', 'proxy-routes'],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
enabled: open,
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTempSelected(new Set(selectedSites.map((site) => site.trim()).filter(Boolean)))
|
||||
setSearchTerm("")
|
||||
}, [open, selectedSites])
|
||||
if (!open) return;
|
||||
setTempSelected(
|
||||
new Set(selectedSites.map((site) => site.trim()).filter(Boolean)),
|
||||
);
|
||||
setSearchTerm('');
|
||||
}, [open, selectedSites]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const routes = routesQuery.data ?? []
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) return routes
|
||||
const routes = routesQuery.data ?? [];
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return routes;
|
||||
return routes.filter((route) => {
|
||||
const domains = (route.zone_domains ?? []).map((item) => item.domain).join(' ')
|
||||
const domains = (route.zone_domains ?? [])
|
||||
.map((item) => item.domain)
|
||||
.join(' ');
|
||||
return (
|
||||
route.site_name.toLowerCase().includes(keyword) ||
|
||||
domains.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}, [routesQuery.data, searchTerm])
|
||||
);
|
||||
});
|
||||
}, [routesQuery.data, searchTerm]);
|
||||
|
||||
const toggleSite = (siteName: string) => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(siteName)) next.delete(siteName)
|
||||
else next.add(siteName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const next = new Set(previous);
|
||||
if (next.has(siteName)) next.delete(siteName);
|
||||
else next.add(siteName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
filteredRoutes.forEach((route) => next.add(route.site_name))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const next = new Set(previous);
|
||||
filteredRoutes.forEach((route) => next.add(route.site_name));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
filteredRoutes.forEach((route) => next.delete(route.site_name))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const next = new Set(previous);
|
||||
filteredRoutes.forEach((route) => next.delete(route.site_name));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择监控站点</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -92,88 +96,111 @@ export function UptimeKumaSiteSelectModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>搜索站点</Label>
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="按名称或域名搜索..."
|
||||
placeholder='按名称或域名搜索...'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
全选过滤项
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleDeselectAll}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDeselectAll}
|
||||
>
|
||||
清空过滤项
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border">
|
||||
<div className='max-h-72 overflow-y-auto rounded-lg border'>
|
||||
{routesQuery.isLoading ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">加载站点列表...</p>
|
||||
<p className='p-4 text-sm text-muted-foreground'>
|
||||
加载站点列表...
|
||||
</p>
|
||||
) : routesQuery.isError ? (
|
||||
<p className="p-4 text-sm text-destructive">
|
||||
<p className='p-4 text-sm text-destructive'>
|
||||
{routesQuery.error instanceof Error
|
||||
? routesQuery.error.message
|
||||
: "加载站点失败"}
|
||||
: '加载站点失败'}
|
||||
</p>
|
||||
) : filteredRoutes.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">无匹配的站点</p>
|
||||
<p className='p-4 text-sm text-muted-foreground'>无匹配的站点</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground">
|
||||
<table className='w-full text-left text-sm'>
|
||||
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2">选择</th>
|
||||
<th className="px-3 py-2">站点名称</th>
|
||||
<th className="px-3 py-2">主域名</th>
|
||||
<th className='w-12 px-3 py-2'>选择</th>
|
||||
<th className='px-3 py-2'>站点名称</th>
|
||||
<th className='px-3 py-2'>主域名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tbody className='divide-y'>
|
||||
{filteredRoutes.map((route) => {
|
||||
const checked = tempSelected.has(route.site_name)
|
||||
const checked = tempSelected.has(route.site_name);
|
||||
return (
|
||||
<tr
|
||||
key={route.id}
|
||||
className="cursor-pointer hover:bg-muted/30"
|
||||
className='cursor-pointer hover:bg-muted/30'
|
||||
onClick={() => toggleSite(route.site_name)}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<td
|
||||
className='px-3 py-2'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
type='checkbox'
|
||||
checked={checked}
|
||||
onChange={() => toggleSite(route.site_name)}
|
||||
className="size-4 rounded border-input"
|
||||
className='size-4 rounded border-input'
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{route.site_name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') || '—'}
|
||||
<td className='px-3 py-2 font-medium'>
|
||||
{route.site_name}
|
||||
</td>
|
||||
<td className='px-3 py-2 text-muted-foreground'>
|
||||
{(route.zone_domains ?? [])
|
||||
.map((item) => item.domain)
|
||||
.join(', ') || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
<p className='text-right text-xs text-muted-foreground'>
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
type='button'
|
||||
onClick={() => {
|
||||
onSave(Array.from(tempSelected))
|
||||
onOpenChange(false)
|
||||
onSave(Array.from(tempSelected));
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
保存选择
|
||||
@@ -181,5 +208,5 @@ export function UptimeKumaSiteSelectModal({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,166 +1,225 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
'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, useSearchParams} from "next/navigation"
|
||||
import {motion} from "motion/react"
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Settings } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } 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"
|
||||
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" />
|
||||
)
|
||||
<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),
|
||||
() =>
|
||||
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),
|
||||
() =>
|
||||
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),
|
||||
() =>
|
||||
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),
|
||||
() =>
|
||||
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),
|
||||
() =>
|
||||
import('@/components/common/settings/info-tab').then((mod) => mod.InfoTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const SystemStatusManager = dynamic(
|
||||
() => import("./components/system-status").then((mod) => mod.SystemStatusManager),
|
||||
() =>
|
||||
import('./components/system-status').then((mod) => mod.SystemStatusManager),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
const OpenFlareOpsSettings = dynamic(
|
||||
() => import("./components/openflare-ops").then((mod) => mod.OpenFlareOpsSettings),
|
||||
() =>
|
||||
import('./components/openflare-ops').then(
|
||||
(mod) => mod.OpenFlareOpsSettings,
|
||||
),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
);
|
||||
|
||||
function systemConfigMap(configs: SystemConfig[]) {
|
||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||
accumulator[config.key] = config
|
||||
return accumulator
|
||||
}, {})
|
||||
accumulator[config.key] = config;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function AdminSettingsPageClient() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
const rawTab = searchParams.get("tab")
|
||||
const validTabs = ["openflare-ops", "security", "operation", "system", "other", "status", "info"]
|
||||
return rawTab && validTabs.includes(rawTab) ? rawTab : "openflare-ops"
|
||||
}, [searchParams])
|
||||
const rawTab = searchParams.get('tab');
|
||||
const validTabs = [
|
||||
'openflare-ops',
|
||||
'security',
|
||||
'operation',
|
||||
'system',
|
||||
'other',
|
||||
'status',
|
||||
'info',
|
||||
];
|
||||
return rawTab && validTabs.includes(rawTab) ? rawTab : 'openflare-ops';
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
router.push(`/admin/settings?tab=${value}`)
|
||||
}
|
||||
router.push(`/admin/settings?tab=${value}`);
|
||||
};
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
queryFn: () => services.adminSystemConfig.listSystemConfigs("system"),
|
||||
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")
|
||||
router.replace('/settings/profile');
|
||||
}
|
||||
}, [user, loading, router])
|
||||
}, [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-primary" />
|
||||
<div className='flex items-center justify-center min-h-[400px]'>
|
||||
<Loader2 className='size-6 animate-spin text-primary' />
|
||||
</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"
|
||||
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 className='flex items-center gap-2'>
|
||||
<Settings className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
<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="openflare-ops" className="px-0 pb-2 text-xs font-semibold">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className='w-full'
|
||||
>
|
||||
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
|
||||
<TabsTrigger
|
||||
value='openflare-ops'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
OpenFlare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||
<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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 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 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 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 value='system' className='focus-visible:outline-none'>
|
||||
<SystemTab
|
||||
configs={configs}
|
||||
systemConfigsQuery={systemConfigsQuery}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="focus-visible:outline-none">
|
||||
<TabsContent value='status' className='focus-visible:outline-none'>
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="focus-visible:outline-none">
|
||||
<TabsContent value='other' className='focus-visible:outline-none'>
|
||||
<OtherTab configs={configs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info" className="focus-visible:outline-none">
|
||||
<TabsContent value='info' className='focus-visible:outline-none'>
|
||||
<InfoTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="openflare-ops" className="focus-visible:outline-none">
|
||||
<TabsContent
|
||||
value='openflare-ops'
|
||||
className='focus-visible:outline-none'
|
||||
>
|
||||
<OpenFlareOpsSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {Suspense} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {AdminSettingsPageClient} from "./page-client"
|
||||
import { Suspense } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AdminSettingsPageClient } from './page-client';
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
<div className='flex items-center justify-center min-h-[400px]'>
|
||||
<Loader2 className='size-6 animate-spin text-primary' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminSettingsPageClient />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client"
|
||||
'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"
|
||||
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';
|
||||
|
||||
/**
|
||||
* 系统配置
|
||||
@@ -38,43 +40,51 @@ function SystemConfigDetailPanel({
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving
|
||||
saving,
|
||||
}: {
|
||||
config: SystemConfig | null
|
||||
editData: Partial<SystemConfig>
|
||||
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
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>
|
||||
<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">
|
||||
<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')}
|
||||
checked={
|
||||
editData.value !== undefined
|
||||
? editData.value === 'true'
|
||||
: config?.value === 'true'
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onEditDataChange('value', checked ? 'true' : 'false')
|
||||
onEditDataChange('value', checked ? 'true' : 'false');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={
|
||||
config?.key && (
|
||||
config.key.endsWith('_limit') ||
|
||||
config?.key &&
|
||||
(config.key.endsWith('_limit') ||
|
||||
config.key.endsWith('_minutes') ||
|
||||
config.key.endsWith('_days') ||
|
||||
config.key.endsWith('_seconds') ||
|
||||
@@ -85,79 +95,100 @@ function SystemConfigDetailPanel({
|
||||
config.key.includes('max_') ||
|
||||
config.key.includes('min_') ||
|
||||
config.key.includes('limit') ||
|
||||
config.key.includes('count')
|
||||
) ? "number" : "text"
|
||||
config.key.includes('count'))
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
step='1'
|
||||
min='0'
|
||||
value={
|
||||
editData.value !== undefined
|
||||
? editData.value
|
||||
: config?.value || ''
|
||||
}
|
||||
placeholder={
|
||||
editData.value === undefined && !config?.value ? '必需' : ''
|
||||
}
|
||||
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
|
||||
const value = e.target.value;
|
||||
if (value === '') {
|
||||
onEditDataChange('value', '')
|
||||
return
|
||||
onEditDataChange('value', '');
|
||||
return;
|
||||
}
|
||||
onEditDataChange('value', value)
|
||||
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]"
|
||||
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'
|
||||
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%]">
|
||||
<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="可选描述"
|
||||
type='text'
|
||||
value={
|
||||
editData.description !== undefined
|
||||
? editData.description
|
||||
: config?.description || ''
|
||||
}
|
||||
placeholder='可选描述'
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
onEditDataChange('description', value)
|
||||
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]"
|
||||
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>
|
||||
<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)
|
||||
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'
|
||||
}`}>
|
||||
<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 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置管理组件
|
||||
*
|
||||
@@ -173,34 +204,39 @@ export function SystemConfigs() {
|
||||
systemConfigsLoading: loading,
|
||||
systemConfigsError: error,
|
||||
refetchSystemConfigs,
|
||||
updateSystemConfig
|
||||
} = useAdmin()
|
||||
updateSystemConfig,
|
||||
} = useAdmin();
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
|
||||
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>(
|
||||
'business',
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
refetchSystemConfigs(activeTab)
|
||||
}, [activeTab, refetchSystemConfigs])
|
||||
refetchSystemConfigs(activeTab);
|
||||
}, [activeTab, refetchSystemConfigs]);
|
||||
|
||||
const getInitialEditData = (config: SystemConfig) => ({
|
||||
value: config.value,
|
||||
visibility: config.visibility,
|
||||
description: config.description
|
||||
})
|
||||
description: config.description,
|
||||
});
|
||||
|
||||
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
|
||||
if (!config) return
|
||||
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
|
||||
})
|
||||
}
|
||||
description: editData.description ?? config.description,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagePage<SystemConfig>
|
||||
title="系统配置"
|
||||
title='系统配置'
|
||||
icon={ShieldCheck}
|
||||
data={configs}
|
||||
loading={loading}
|
||||
@@ -209,23 +245,72 @@ export function SystemConfigs() {
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
getId={(config) => config.key}
|
||||
emptyDescription="未发现系统配置"
|
||||
loadingDescription="配置加载中"
|
||||
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>
|
||||
<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" },
|
||||
{
|
||||
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 }) => (
|
||||
renderDetail={({
|
||||
selected,
|
||||
hovered,
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving,
|
||||
}) => (
|
||||
<SystemConfigDetailPanel
|
||||
config={selected || hovered}
|
||||
editData={editData}
|
||||
@@ -235,7 +320,7 @@ export function SystemConfigs() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemConfigPage() {
|
||||
@@ -243,5 +328,5 @@ export default function SystemConfigPage() {
|
||||
<AdminProvider>
|
||||
<SystemConfigs />
|
||||
</AdminProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,201 +1,278 @@
|
||||
"use client"
|
||||
'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 { 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"
|
||||
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: "失败",
|
||||
}
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: "系统",
|
||||
manual: "手动",
|
||||
retry: "重试",
|
||||
schedule: "定时",
|
||||
}
|
||||
system: '系统',
|
||||
manual: '手动',
|
||||
retry: '重试',
|
||||
schedule: '定时',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
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")
|
||||
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`
|
||||
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"
|
||||
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 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"],
|
||||
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,
|
||||
}),
|
||||
})
|
||||
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],
|
||||
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"] })
|
||||
toast.success('任务已重新下发', {
|
||||
description: `新任务 ID:${taskID}`,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'task-executions'],
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error("任务重试失败", {
|
||||
description: err.message || "未知错误",
|
||||
})
|
||||
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 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)
|
||||
}
|
||||
setExecutionPreview(execution);
|
||||
setSelectedExecutionId(execution.id);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
|
||||
setExecutionStatus(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
const handleStatusChange = (value: TaskExecutionStatus | 'all') => {
|
||||
setExecutionStatus(value);
|
||||
setExecutionsPage(1);
|
||||
};
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setExecutionTaskType(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
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="状态" />
|
||||
<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>
|
||||
<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="任务类型" />
|
||||
<Select
|
||||
value={executionTaskType}
|
||||
onValueChange={handleTaskTypeChange}
|
||||
>
|
||||
<SelectTrigger size='sm' className='w-[180px]'>
|
||||
<SelectValue placeholder='任务类型' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部任务</SelectItem>
|
||||
<SelectItem value='all'>全部任务</SelectItem>
|
||||
{taskTypes.map((task) => (
|
||||
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
|
||||
<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
|
||||
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 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="加载任务执行记录中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载任务执行记录中...'
|
||||
/>
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
|
||||
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[900px]">
|
||||
<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 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"
|
||||
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 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>
|
||||
@@ -204,18 +281,21 @@ export function TaskExecutionsManager() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
|
||||
<Badge variant='outline'>
|
||||
{TRIGGER_LABELS[execution.triggered_by] ||
|
||||
execution.triggered_by}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
<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">
|
||||
<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 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">
|
||||
<TableCell className='font-mono text-[11px] text-muted-foreground'>
|
||||
{formatDateTime(execution.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -225,150 +305,192 @@ export function TaskExecutionsManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<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">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={executionsPage <= 1 || executionsLoading}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<ChevronLeft className='size-4' />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setExecutionsPage((page) => Math.min(totalPages, page + 1))
|
||||
}
|
||||
disabled={executionsPage >= totalPages || executionsLoading}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-4" />
|
||||
<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">
|
||||
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
|
||||
<SheetHeader className='border-b'>
|
||||
<SheetTitle>任务执行详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
|
||||
{selectedExecution?.task_name ||
|
||||
selectedExecution?.task_type ||
|
||||
'任务记录'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div className='flex-1 overflow-y-auto px-4 pb-4'>
|
||||
{detailLoading && !selectedExecution ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
|
||||
<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">
|
||||
<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}
|
||||
{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 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 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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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 className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className='grid gap-2'>
|
||||
<Label>开始时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className='grid gap-2'>
|
||||
<Label>结束时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.finished_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className='grid gap-2'>
|
||||
<Label>更新时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<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 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">
|
||||
<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">
|
||||
<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">
|
||||
<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 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">
|
||||
<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 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="未选择任务记录" />
|
||||
<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" />}
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,150 @@
|
||||
"use client"
|
||||
'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 { 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 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"
|
||||
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 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",
|
||||
}
|
||||
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") : ""
|
||||
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
|
||||
setDate(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = new Date(newDate)
|
||||
const updated = new Date(newDate);
|
||||
|
||||
if (date) {
|
||||
updated.setHours(date.getHours())
|
||||
updated.setMinutes(date.getMinutes())
|
||||
updated.setSeconds(date.getSeconds())
|
||||
updated.setHours(date.getHours());
|
||||
updated.setMinutes(date.getMinutes());
|
||||
updated.setSeconds(date.getSeconds());
|
||||
} else {
|
||||
updated.setHours(0, 0, 0, 0)
|
||||
updated.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
setDate(updated)
|
||||
}
|
||||
setDate(updated);
|
||||
};
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
if (!newTime) return
|
||||
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)
|
||||
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)
|
||||
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">
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
variant={'outline'}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal text-xs h-8",
|
||||
!date && "text-muted-foreground"
|
||||
'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>}
|
||||
<CalendarIcon className='mr-1 size-3' />
|
||||
{date ? format(date, 'yyyy-MM-dd') : <span>选择日期</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode="single"
|
||||
mode='single'
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
locale={zhCN}
|
||||
@@ -118,197 +152,218 @@ function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<div className='w-[120px]'>
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
type='time'
|
||||
step='1'
|
||||
value={timeString}
|
||||
onChange={handleTimeChange}
|
||||
className="text-xs font-mono"
|
||||
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 [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 [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 [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)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await services.adminTask.getTaskTypes();
|
||||
setTaskTypes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
fetchTaskTypes();
|
||||
}, [fetchTaskTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === 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)
|
||||
const initialValues: Record<string, string> = {};
|
||||
targetTask.params.forEach((p) => {
|
||||
initialValues[p.name] = '';
|
||||
});
|
||||
setParamValues(initialValues);
|
||||
} else {
|
||||
setParamValues({})
|
||||
setParamValues({});
|
||||
}
|
||||
}
|
||||
}, [selectedTaskType, taskTypes])
|
||||
}, [selectedTaskType, taskTypes]);
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!selectedTaskType) return
|
||||
if (!selectedTaskType) return;
|
||||
|
||||
try {
|
||||
setDispatching(true)
|
||||
setDispatching(true);
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
|
||||
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: selectedTaskType
|
||||
}
|
||||
task_type: selectedTaskType,
|
||||
};
|
||||
|
||||
if (targetTask?.supports_time) {
|
||||
if (startTime) params.start_time = startTime.toISOString()
|
||||
if (endTime) params.end_time = endTime.toISOString()
|
||||
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
|
||||
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)
|
||||
const { payload, error } = buildTaskPayload(
|
||||
targetTask.params,
|
||||
paramValues,
|
||||
);
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setDispatching(false)
|
||||
return
|
||||
toast.error(error);
|
||||
setDispatching(false);
|
||||
return;
|
||||
}
|
||||
params.payload = payload ?? undefined
|
||||
params.payload = payload ?? undefined;
|
||||
}
|
||||
|
||||
const taskID = await services.adminTask.dispatchTask(params)
|
||||
const taskID = await services.adminTask.dispatchTask(params);
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
|
||||
})
|
||||
setDialogOpen(false)
|
||||
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
|
||||
setStartTime(undefined)
|
||||
setEndTime(undefined)
|
||||
setUserId("")
|
||||
setParamValues({})
|
||||
setStartTime(undefined);
|
||||
setEndTime(undefined);
|
||||
setUserId('');
|
||||
setParamValues({});
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
setDispatching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDispatchDialog = (type: string) => {
|
||||
setSelectedTaskType(type)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
setSelectedTaskType(type);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
return taskTypes.find((t) => t.type === selectedTaskType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className='space-y-6'>
|
||||
<br />
|
||||
|
||||
<div className="space-y-6">
|
||||
<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 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="加载任务类型中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Layers}
|
||||
description='加载任务类型中...'
|
||||
/>
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
|
||||
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<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
|
||||
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ task.type }-${ index }`}
|
||||
key={`${task.type}-${index}`}
|
||||
className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
|
||||
config.gradient
|
||||
'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]">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<div className='pt-4 mt-1'>
|
||||
<Button
|
||||
className="w-full h-7 text-xs"
|
||||
variant="secondary"
|
||||
className='w-full h-7 text-xs'
|
||||
variant='secondary'
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
<Play className='size-3 mr-1' />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -316,81 +371,111 @@ export function TaskManager() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<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">
|
||||
<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
|
||||
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 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 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()
|
||||
const targetTask = getSelectedTaskMeta();
|
||||
if (targetTask?.supports_time) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<div className='grid gap-2'>
|
||||
<Label>开始时间</Label>
|
||||
<DatePickerWithTime date={startTime} setDate={setStartTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
|
||||
<DatePickerWithTime
|
||||
date={startTime}
|
||||
setDate={setStartTime}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择同步的起始时间点
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className='grid gap-2'>
|
||||
<Label>结束时间</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择同步的结束时间点
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return null
|
||||
const targetTask = getSelectedTaskMeta();
|
||||
if (
|
||||
!targetTask ||
|
||||
!targetTask.params ||
|
||||
targetTask.params.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<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>}
|
||||
{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 }))}
|
||||
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">
|
||||
<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' }))}
|
||||
onCheckedChange={(checked) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -398,47 +483,68 @@ export function TaskManager() {
|
||||
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 }))}
|
||||
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>
|
||||
<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
|
||||
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" />
|
||||
<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
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
|
||||
<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" />}
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,119 @@
|
||||
"use client"
|
||||
'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 { 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"
|
||||
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[]>([])
|
||||
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)
|
||||
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)
|
||||
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>>({})
|
||||
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)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [schedulesData, taskTypesData] = await Promise.all([
|
||||
services.adminTask.listSchedules(),
|
||||
services.adminTask.getTaskTypes()
|
||||
])
|
||||
setSchedules(schedulesData || [])
|
||||
setTaskTypes(taskTypesData || [])
|
||||
services.adminTask.getTaskTypes(),
|
||||
]);
|
||||
setSchedules(schedulesData || []);
|
||||
setTaskTypes(taskTypesData || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
|
||||
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Track task type selection to initialize parameters
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === 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)
|
||||
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)
|
||||
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)
|
||||
const initialValues: Record<string, string> = {};
|
||||
targetTask?.params?.forEach((p) => {
|
||||
initialValues[p.name] = '';
|
||||
});
|
||||
setParamValues(initialValues);
|
||||
}
|
||||
} else {
|
||||
setParamValues({})
|
||||
setParamValues({});
|
||||
}
|
||||
}, [selectedTaskType, taskTypes, editingSchedule])
|
||||
}, [selectedTaskType, taskTypes, editingSchedule]);
|
||||
|
||||
const handleToggleActive = async (schedule: Schedule) => {
|
||||
try {
|
||||
@@ -103,61 +122,70 @@ export function TaskSchedulesManager() {
|
||||
task_type: schedule.task_type,
|
||||
cron: schedule.cron,
|
||||
payload: schedule.payload,
|
||||
is_active: !schedule.is_active
|
||||
})
|
||||
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}`)
|
||||
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 : "未知错误"
|
||||
})
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
toast.error('任务名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (!cron.trim()) {
|
||||
toast.error("Cron 表达式不能为空")
|
||||
return
|
||||
toast.error('Cron 表达式不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true)
|
||||
setSubmitLoading(true);
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
let payloadStr = '{}'
|
||||
const targetTask = taskTypes.find((t) => t.type === selectedTaskType);
|
||||
let payloadStr = '{}';
|
||||
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
const { payload, error } = buildTaskPayload(
|
||||
targetTask.params,
|
||||
paramValues,
|
||||
);
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setSubmitLoading(false)
|
||||
return
|
||||
toast.error(error);
|
||||
setSubmitLoading(false);
|
||||
return;
|
||||
}
|
||||
payloadStr = payload ?? '{}'
|
||||
payloadStr = payload ?? '{}';
|
||||
}
|
||||
|
||||
if (editingSchedule) {
|
||||
@@ -166,145 +194,182 @@ export function TaskSchedulesManager() {
|
||||
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("定时任务更新成功")
|
||||
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("定时任务创建成功")
|
||||
is_active: isActive,
|
||||
};
|
||||
const created = await services.adminTask.createSchedule(req);
|
||||
setSchedules((prev) => [created, ...prev]);
|
||||
toast.success('定时任务创建成功');
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error("提交失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
toast.error('提交失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setDeletingId(id)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
setDeletingId(id);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingId) return
|
||||
if (!deletingId) return;
|
||||
try {
|
||||
setDeleteLoading(true)
|
||||
await services.adminTask.deleteSchedule(deletingId)
|
||||
setSchedules(prev => prev.filter(s => s.id !== deletingId))
|
||||
toast.success("定时任务删除成功")
|
||||
setDeleteOpen(false)
|
||||
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 : "未知错误"
|
||||
})
|
||||
toast.error('删除失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
return taskTypes.find((t) => t.type === selectedTaskType);
|
||||
};
|
||||
|
||||
const getTaskName = (type: string) => {
|
||||
const meta = taskTypes.find(t => t.type === type)
|
||||
return meta ? meta.name : type
|
||||
}
|
||||
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" />}
|
||||
<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
|
||||
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 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="加载定时任务配置中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Clock}
|
||||
description='加载定时任务配置中...'
|
||||
/>
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
|
||||
<EmptyStateWithBorder
|
||||
icon={Clock}
|
||||
description='暂无定时任务配置,点击上方按钮新增'
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[800px]">
|
||||
<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 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 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 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">
|
||||
<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 || "{}"}
|
||||
<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">
|
||||
<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">
|
||||
<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"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
>
|
||||
<Edit2 className="size-3.5" />
|
||||
<Edit2 className='size-3.5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/90"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 text-destructive hover:text-destructive/90'
|
||||
onClick={() => openDeleteDialog(schedule.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -317,36 +382,38 @@ export function TaskSchedulesManager() {
|
||||
|
||||
{/* Add / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingSchedule ? '修改定时任务' : '新增定时任务'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置定时任务的调度参数和运行载荷。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-name">任务名称</Label>
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-name'>任务名称</Label>
|
||||
<Input
|
||||
id="sched-name"
|
||||
placeholder="例如:每日数据清理"
|
||||
id='sched-name'
|
||||
placeholder='例如:每日数据清理'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
className='text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-type">关联异步任务</Label>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-type'>关联异步任务</Label>
|
||||
<select
|
||||
id="sched-type"
|
||||
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"
|
||||
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 => (
|
||||
{taskTypes.map((t) => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.name} ({t.type})
|
||||
</option>
|
||||
@@ -354,66 +421,96 @@ export function TaskSchedulesManager() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-cron">Cron 表达式</Label>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-cron'>Cron 表达式</Label>
|
||||
<Input
|
||||
id="sched-cron"
|
||||
placeholder="e.g. 0 */2 * * * 或 @daily"
|
||||
id='sched-cron'
|
||||
placeholder='e.g. 0 */2 * * * 或 @daily'
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
className='text-xs font-mono'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
|
||||
<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 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"
|
||||
id='sched-active'
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
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" />
|
||||
<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>
|
||||
<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">
|
||||
<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>}
|
||||
{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 }))}
|
||||
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">
|
||||
<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' }))}
|
||||
onCheckedChange={(checked) =>
|
||||
setParamValues((prev) => ({
|
||||
...prev,
|
||||
[param.name]: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -421,27 +518,43 @@ export function TaskSchedulesManager() {
|
||||
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 }))}
|
||||
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>
|
||||
<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
|
||||
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" />}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitLoading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
{submitLoading && <Spinner className='size-3 mr-1' />}
|
||||
{editingSchedule ? '保存修改' : '确认创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -450,24 +563,34 @@ export function TaskSchedulesManager() {
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<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">
|
||||
<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
|
||||
variant='destructive'
|
||||
onClick={handleDelete}
|
||||
disabled={deleteLoading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
{deleteLoading && <Spinner className='size-3 mr-1' />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,80 @@
|
||||
"use client"
|
||||
'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"
|
||||
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 searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const activeTab = searchParams.get('tab') || 'tasks';
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
router.push(`/admin/tasks?tab=${value}`)
|
||||
}
|
||||
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 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>
|
||||
<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">
|
||||
<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
|
||||
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
|
||||
value='executions'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
任务日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks" className="space-y-4 outline-none">
|
||||
<TabsContent value='tasks' className='space-y-4 outline-none'>
|
||||
<TaskManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="schedules" className="space-y-4 outline-none">
|
||||
<TabsContent value='schedules' className='space-y-4 outline-none'>
|
||||
<TaskSchedulesManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="executions" className="space-y-4 outline-none">
|
||||
<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>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex items-center justify-center min-h-[400px]'>
|
||||
<Spinner className='h-8 w-8' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TasksPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
"use client"
|
||||
'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"
|
||||
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: "",
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
}
|
||||
};
|
||||
|
||||
export function CreateUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
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>>({})
|
||||
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({})
|
||||
setForm(emptyForm);
|
||||
setErrors({});
|
||||
} else {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}, [isOpen])
|
||||
}, [isOpen]);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = "用户名不能为空"
|
||||
newErrors.username = '用户名不能为空';
|
||||
} else if (form.username.trim().length < 3) {
|
||||
newErrors.username = "用户名长度不能少于 3 位"
|
||||
newErrors.username = '用户名长度不能少于 3 位';
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
newErrors.email = '邮箱不能为空';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
newErrors.email = '邮箱格式不正确';
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
newErrors.password = "密码不能为空"
|
||||
newErrors.password = '密码不能为空';
|
||||
} else if (form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
newErrors.password = '密码长度不能少于 8 位';
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setSaving(true)
|
||||
setSaving(true);
|
||||
try {
|
||||
await createUser({
|
||||
...form,
|
||||
username: form.username.trim(),
|
||||
nickname: form.nickname?.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
})
|
||||
onClose()
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// Errors are already handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -93,90 +93,106 @@ export function CreateUserModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<form onSubmit={handleSave} className='space-y-4 pt-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='username'>用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
id='username'
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="请输入用户名 (至少 3 位)"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, username: e.target.value }))
|
||||
}
|
||||
placeholder='请输入用户名 (至少 3 位)'
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">{errors.username}</p>
|
||||
<p className='text-xs text-destructive'>{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='nickname'>昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
id='nickname'
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nickname: e.target.value }))
|
||||
}
|
||||
placeholder='请输入昵称'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='email'>邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
id='email'
|
||||
type='email'
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder='请输入邮箱地址'
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
<p className='text-xs text-destructive'>{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='password'>密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
id='password'
|
||||
type='password'
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="请输入登录密码 (至少 8 位)"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
placeholder='请输入登录密码 (至少 8 位)'
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
<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 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 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 }))}
|
||||
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 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 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 }))}
|
||||
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}>
|
||||
<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 type='submit' disabled={saving} variant='secondary'>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
'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 {useAuth} from "@/components/providers/auth-provider"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
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 {useAuth} from '@/components/providers/auth-provider';
|
||||
import type {AdminUser} from '@/lib/services/admin';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Loader2} from "lucide-react"
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
interface EditUserForm {
|
||||
nickname: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
password?: string
|
||||
nickname: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function EditUserModal({
|
||||
@@ -33,115 +33,116 @@ export function EditUserModal({
|
||||
onClose,
|
||||
user,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: AdminUser | null
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: AdminUser | null;
|
||||
}) {
|
||||
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
|
||||
const { user: currentUser } = useAuth()
|
||||
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
|
||||
const isSelf =
|
||||
currentUser && user && currentUser.id.toString() === user.id.toString();
|
||||
|
||||
const [form, setForm] = useState<EditUserForm>({
|
||||
nickname: "",
|
||||
email: "",
|
||||
nickname: '',
|
||||
email: '',
|
||||
is_admin: false,
|
||||
password: "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
password: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let active = true;
|
||||
if (isOpen && user) {
|
||||
setForm({
|
||||
nickname: user.nickname || "",
|
||||
email: user.email || "",
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
is_admin: user.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
setErrors({})
|
||||
password: '',
|
||||
});
|
||||
setErrors({});
|
||||
|
||||
getUserDetail(user.id)
|
||||
.then((detail) => {
|
||||
if (active && detail) {
|
||||
setForm({
|
||||
nickname: detail.nickname || "",
|
||||
email: detail.email || "",
|
||||
nickname: detail.nickname || '',
|
||||
email: detail.email || '',
|
||||
is_admin: detail.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
password: '',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore fetching error
|
||||
})
|
||||
});
|
||||
} else {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isOpen, user, getUserDetail])
|
||||
active = false;
|
||||
};
|
||||
}, [isOpen, user, getUserDetail]);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
newErrors.email = '邮箱不能为空';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
newErrors.email = '邮箱格式不正确';
|
||||
}
|
||||
|
||||
if (form.password && form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
newErrors.password = '密码长度不能少于 8 位';
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!user || !validate()) return
|
||||
e.preventDefault();
|
||||
if (!user || !validate()) return;
|
||||
|
||||
setSaving(true)
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: form.is_admin,
|
||||
password: form.password?.trim() || undefined,
|
||||
})
|
||||
onClose()
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
if (!user) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteUser(user)
|
||||
onClose()
|
||||
await deleteUser(user);
|
||||
onClose();
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setSaving(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -149,89 +150,102 @@ export function EditUserModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
|
||||
<form onSubmit={handleSave} className='space-y-4 pt-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='username'
|
||||
className='text-xs text-muted-foreground'
|
||||
>
|
||||
用户名 (不可修改)
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
id='username'
|
||||
value={user.username}
|
||||
disabled
|
||||
className="bg-muted/50 cursor-not-allowed select-none font-mono"
|
||||
className='bg-muted/50 cursor-not-allowed select-none font-mono'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='nickname'>昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
id='nickname'
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nickname: e.target.value }))
|
||||
}
|
||||
placeholder='请输入昵称'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='email'>邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
id='email'
|
||||
type='email'
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder='请输入邮箱地址'
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
<p className='text-xs text-destructive'>{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">重置密码 (选填)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='password'>重置密码 (选填)</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
id='password'
|
||||
type='password'
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
<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 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">
|
||||
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
|
||||
<div className='font-medium text-sm'>管理员权限</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{isSelf
|
||||
? '不能撤销当前登录用户的管理员权限。'
|
||||
: '开启后此账号将拥有后台管理权限。'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_admin}
|
||||
disabled={!!isSelf || saving}
|
||||
onCheckedChange={async (checked) => {
|
||||
setForm((prev) => ({ ...prev, is_admin: checked }))
|
||||
setSaving(true)
|
||||
setForm((prev) => ({ ...prev, is_admin: checked }));
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: checked,
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
setForm((prev) => ({ ...prev, is_admin: !checked }))
|
||||
setForm((prev) => ({ ...prev, is_admin: !checked }));
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t mt-2">
|
||||
<div className='flex justify-between items-center pt-2 border-t mt-2'>
|
||||
<div>
|
||||
{!user.is_admin && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
@@ -239,12 +253,17 @@ export function EditUserModal({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
<Button type='submit' disabled={saving} variant='secondary'>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,24 +276,25 @@ export function EditUserModal({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
确定要删除用户 {user.nickname || user.username}{' '}
|
||||
吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Globe,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
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 {formatDateTime} from "@/lib/utils"
|
||||
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 { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
selectedUser: AdminUser | null
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailLoading: boolean
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||
selectedUser: AdminUser | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detailLoading: boolean;
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
@@ -25,36 +33,43 @@ export function UserDetailSheet({
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||
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>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
@@ -62,91 +77,132 @@ export function UserDetailSheet({
|
||||
</div>
|
||||
|
||||
{detailLoading && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<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 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" />
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
<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 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 ? "正常" : "禁用"}
|
||||
<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 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 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 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 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>
|
||||
@@ -154,20 +210,20 @@ export function UserDetailSheet({
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||
<div className='p-4 border-t bg-background/80 backdrop-blur-md shrink-0'>
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
|
||||
variant={selectedUser.is_active ? 'destructive' : 'default'}
|
||||
className='w-full h-9 text-xs font-medium transition-all active:scale-[0.98]'
|
||||
onClick={() => onStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3 mr-1" />
|
||||
<ShieldCheck className='size-3 mr-1' />
|
||||
封禁账户
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="size-3 mr-1" />
|
||||
<UserCheck className='size-3 mr-1' />
|
||||
解除封禁
|
||||
</>
|
||||
)}
|
||||
@@ -179,5 +235,5 @@ export function UserDetailSheet({
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {ChevronDown, ChevronLeft, ChevronRight, Filter, Loader2, Search, X} from "lucide-react"
|
||||
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"
|
||||
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 {
|
||||
@@ -26,33 +39,35 @@ export function UserFilterBar() {
|
||||
setSearchUsername,
|
||||
setSearchEmail,
|
||||
setStatusFilter,
|
||||
fetchUsers
|
||||
} = useAdminUsers()
|
||||
fetchUsers,
|
||||
} = useAdminUsers();
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const hasSearchFilter = Boolean(
|
||||
searchUserId || searchUsername || searchEmail,
|
||||
);
|
||||
|
||||
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">
|
||||
<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"
|
||||
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"
|
||||
'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" />
|
||||
<Search className='size-3 mr-1' />
|
||||
搜索
|
||||
{hasSearchFilter && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Separator orientation='vertical' className='mx-1' />
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
variant='default'
|
||||
className='text-[10px] h-3 px-1 rounded-full'
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
@@ -60,35 +75,35 @@ export function UserFilterBar() {
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 p-3" align="start">
|
||||
<div className="space-y-2.5">
|
||||
<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..."
|
||||
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..."
|
||||
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)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入邮箱..."
|
||||
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
|
||||
placeholder='输入邮箱...'
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-6 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='w-full h-6 text-xs'
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
setSearchUserId('');
|
||||
setSearchUsername('');
|
||||
setSearchEmail('');
|
||||
}}
|
||||
>
|
||||
清除
|
||||
@@ -101,21 +116,21 @@ export function UserFilterBar() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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"
|
||||
'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" />
|
||||
<Filter className='size-3' />
|
||||
状态
|
||||
{statusFilter !== 'all' && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Separator orientation='vertical' className='mx-1' />
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
variant='default'
|
||||
className='text-[10px] h-3 px-1 rounded-full'
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
@@ -123,107 +138,130 @@ export function UserFilterBar() {
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
<DropdownMenuContent className='w-[120px]' align='start'>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
|
||||
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>
|
||||
<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') }}
|
||||
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>
|
||||
<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') }}
|
||||
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>
|
||||
<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" />
|
||||
<Separator orientation='vertical' className='h-6 hidden sm:block' />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
setStatusFilter('all')
|
||||
setSearchUserId('');
|
||||
setSearchUsername('');
|
||||
setSearchEmail('');
|
||||
setStatusFilter('all');
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className="size-3" />
|
||||
<X className='size-3' />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="lg:hidden" />
|
||||
<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">
|
||||
<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">
|
||||
<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"
|
||||
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" />
|
||||
<ChevronLeft className='size-3' />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
|
||||
<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"
|
||||
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" />
|
||||
<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}>
|
||||
<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" />
|
||||
<ChevronDown className='size-3 opacity-50' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuContent align='end'>
|
||||
{[20, 50, 100].map((size) => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => setPageSize(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
className={cn(
|
||||
'font-mono text-xs',
|
||||
pageSize === size && 'bg-accent',
|
||||
)}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
@@ -232,16 +270,16 @@ export function UserFilterBar() {
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-6 w-6 border-dashed shadow-none'
|
||||
onClick={() => fetchUsers(true)}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
title='刷新数据'
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,93 @@
|
||||
"use client"
|
||||
'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, Pencil, Plus, UserRound, UserX,} from "lucide-react"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
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, Pencil, Plus, UserRound, UserX } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
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"
|
||||
import {EditUserModal} from "./components/edit-user-modal"
|
||||
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';
|
||||
import { EditUserModal } from './components/edit-user-modal';
|
||||
|
||||
export default function UsersPage() {
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
fetchUsers,
|
||||
getUserDetail,
|
||||
updateUserStatus
|
||||
} = useAdminUsers()
|
||||
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
|
||||
useAdminUsers();
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleStatusToggle = async (user: AdminUser) => {
|
||||
await updateUserStatus(user)
|
||||
await updateUserStatus(user);
|
||||
|
||||
if (selectedUser?.id === user.id) {
|
||||
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
|
||||
setSelectedUser((prev) =>
|
||||
prev ? { ...prev, is_active: !prev.is_active } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDetail = async (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
setSelectedUser(user);
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
|
||||
try {
|
||||
const detail = await getUserDetail(user.id)
|
||||
setSelectedUser(detail)
|
||||
const detail = await getUserDetail(user.id);
|
||||
setSelectedUser(detail);
|
||||
} catch {
|
||||
setSelectedUser(user)
|
||||
setSelectedUser(user);
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
<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 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>
|
||||
<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
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增用户
|
||||
</Button>
|
||||
</div>
|
||||
@@ -85,75 +96,108 @@ export default function UsersPage() {
|
||||
<UserFilterBar />
|
||||
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
|
||||
<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="加载用户列表中..." />
|
||||
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
|
||||
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<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-[160px] 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="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>
|
||||
<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-[160px] 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='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>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{user.email || "-"}
|
||||
</TableCell>
|
||||
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4'>
|
||||
{user.email || '-'}
|
||||
</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">
|
||||
<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>
|
||||
@@ -161,44 +205,56 @@ export default function UsersPage() {
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75"
|
||||
className='scale-75'
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
|
||||
<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
|
||||
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 side='top' className='text-xs'>
|
||||
查看详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
|
||||
<Pencil className="size-3" />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setEditTarget(user)}
|
||||
>
|
||||
<Pencil className='size-3' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
编辑用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
@@ -219,10 +275,11 @@ export default function UsersPage() {
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* 新建用户模态弹窗 */}
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
<CreateUserModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,108 +1,129 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
|
||||
import type {ApplyLogItem} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import type { ApplyLogItem } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface LogDetailSheetProps {
|
||||
log: ApplyLogItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
log: ApplyLogItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
if (result === "success") {
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
|
||||
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" />
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (result === "warning") {
|
||||
if (result === 'warning') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
|
||||
variant='outline'
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
|
||||
variant='outline'
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function LogDetailSheet({ log, open, onOpenChange }: LogDetailSheetProps) {
|
||||
export function LogDetailSheet({
|
||||
log,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LogDetailSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md w-full p-0 flex flex-col gap-0">
|
||||
<SheetHeader className="px-5 py-4 border-b">
|
||||
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>应用日志详情</SheetTitle>
|
||||
<SheetDescription>查看单条应用日志的完整结果与校验信息。</SheetDescription>
|
||||
<SheetDescription>
|
||||
查看单条应用日志的完整结果与校验信息。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{log ? (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{getResultBadge(log.result)}
|
||||
<Badge variant="outline" className="text-[10px] rounded-full">
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
Node: {log.node_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] rounded-full">
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
版本: {log.version}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs">
|
||||
<div className='grid gap-3 text-xs'>
|
||||
<div>
|
||||
<p className="text-muted-foreground">创建时间</p>
|
||||
<p className="font-medium">{formatDateTime(log.created_at)}</p>
|
||||
<p className='text-muted-foreground'>创建时间</p>
|
||||
<p className='font-medium'>{formatDateTime(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">目标 Checksum</p>
|
||||
<p className="font-mono break-all">{log.checksum || "—"}</p>
|
||||
<p className='text-muted-foreground'>目标 Checksum</p>
|
||||
<p className='font-mono break-all'>{log.checksum || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">主配置摘要</p>
|
||||
<p className="font-mono break-all">{log.main_config_checksum || "—"}</p>
|
||||
<p className='text-muted-foreground'>主配置摘要</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.main_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">路由配置摘要</p>
|
||||
<p className="font-mono break-all">{log.route_config_checksum || "—"}</p>
|
||||
<p className='text-muted-foreground'>路由配置摘要</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.route_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">支持文件数</p>
|
||||
<p className="font-medium">{log.support_file_count}</p>
|
||||
<p className='text-muted-foreground'>支持文件数</p>
|
||||
<p className='font-medium'>{log.support_file_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">消息</p>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words">{log.message || "—"}</pre>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
|
||||
消息
|
||||
</p>
|
||||
<pre className='text-xs whitespace-pre-wrap break-words'>
|
||||
{log.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from "react"
|
||||
import Link from "next/link"
|
||||
import {useSearchParams} from "next/navigation"
|
||||
import {ClipboardList, Eye, RefreshCw, Search, Trash2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -18,226 +18,248 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {type ApplyLogItem, ApplyLogService,} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { type ApplyLogItem, ApplyLogService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {LogDetailSheet} from "./components/log-detail-sheet"
|
||||
import { LogDetailSheet } from './components/log-detail-sheet';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100]
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
function truncateHash(value: string) {
|
||||
if (!value) return "—"
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value
|
||||
if (!value) return '—';
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
if (result === "success") {
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
|
||||
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" />
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (result === "warning") {
|
||||
if (result === 'warning') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
|
||||
variant='outline'
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
|
||||
variant='outline'
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplyLogsPageClient() {
|
||||
const searchParams = useSearchParams()
|
||||
const initialNodeId = searchParams.get("node_id")?.trim() ?? ""
|
||||
const searchParams = useSearchParams();
|
||||
const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
|
||||
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId)
|
||||
const [nodeFilter, setNodeFilter] = useState(initialNodeId)
|
||||
const [pageNo, setPageNo] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId);
|
||||
const [nodeFilter, setNodeFilter] = useState(initialNodeId);
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const [rows, setRows] = useState<ApplyLogItem[]>([])
|
||||
const [current, setCurrent] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPage, setTotalPage] = useState(0)
|
||||
const [rows, setRows] = useState<ApplyLogItem[]>([]);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id))
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: "总记录数", value: total },
|
||||
{ label: "当前页", value: current },
|
||||
{ label: "总页数", value: totalPage },
|
||||
{ label: "当前页节点数", value: nodeIds.size },
|
||||
]
|
||||
}, [rows, total, current, totalPage])
|
||||
{ label: '总记录数', value: total },
|
||||
{ label: '当前页', value: current },
|
||||
{ label: '总页数', value: totalPage },
|
||||
{ label: '当前页节点数', value: nodeIds.size },
|
||||
];
|
||||
}, [rows, total, current, totalPage]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await ApplyLogService.list({
|
||||
node_id: nodeFilter || undefined,
|
||||
pageNo,
|
||||
pageSize,
|
||||
})
|
||||
setRows(data.rows)
|
||||
setCurrent(data.current)
|
||||
setTotal(data.total)
|
||||
setTotalPage(data.totalPage)
|
||||
});
|
||||
setRows(data.rows);
|
||||
setCurrent(data.current);
|
||||
setTotal(data.total);
|
||||
setTotalPage(data.totalPage);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败")
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nodeFilter, pageNo, pageSize])
|
||||
}, [nodeFilter, pageNo, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs()
|
||||
}, [fetchLogs])
|
||||
void fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = searchParams.get("node_id")?.trim()
|
||||
if (!nodeId) return
|
||||
setNodeFilterInput(nodeId)
|
||||
setNodeFilter(nodeId)
|
||||
setPageNo(1)
|
||||
}, [searchParams])
|
||||
const nodeId = searchParams.get('node_id')?.trim();
|
||||
if (!nodeId) return;
|
||||
setNodeFilterInput(nodeId);
|
||||
setNodeFilter(nodeId);
|
||||
setPageNo(1);
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPageNo(1)
|
||||
setNodeFilter(nodeFilterInput.trim())
|
||||
}
|
||||
setPageNo(1);
|
||||
setNodeFilter(nodeFilterInput.trim());
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setNodeFilterInput("")
|
||||
setNodeFilter("")
|
||||
setPageNo(1)
|
||||
}
|
||||
setNodeFilterInput('');
|
||||
setNodeFilter('');
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const handleCleanupLogs = async () => {
|
||||
setCleaning(true)
|
||||
setCleaning(true);
|
||||
try {
|
||||
const result = await ApplyLogService.cleanup({ delete_all: true })
|
||||
toast.success("应用日志已清空", {
|
||||
const result = await ApplyLogService.cleanup({ delete_all: true });
|
||||
toast.success('应用日志已清空', {
|
||||
description: `共删除 ${result.deleted_count} 条记录`,
|
||||
})
|
||||
setCleanupOpen(false)
|
||||
setPageNo(1)
|
||||
await fetchLogs()
|
||||
});
|
||||
setCleanupOpen(false);
|
||||
setPageNo(1);
|
||||
await fetchLogs();
|
||||
} catch (err) {
|
||||
toast.error("清空应用日志失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('清空应用日志失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
setCleaning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="size-5 text-primary" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ClipboardList className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">应用日志</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>应用日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={loading}>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => void fetchLogs()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
disabled={loading || cleaning || total === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清空日志
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/nodes">返回节点</Link>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/nodes'>返回节点</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-dashed px-4 py-3 bg-background"
|
||||
className='rounded-lg border border-dashed px-4 py-3 bg-background'
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{item.value}</p>
|
||||
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Node ID</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<div className='flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<div className='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>Node ID</p>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
value={nodeFilterInput}
|
||||
onChange={(e) => setNodeFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
placeholder="输入 node_id 过滤应用日志"
|
||||
className="pl-8 h-9 text-xs"
|
||||
placeholder='输入 node_id 过滤应用日志'
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
每页条数
|
||||
</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number.parseInt(value, 10))
|
||||
setPageNo(1)
|
||||
setPageSize(Number.parseInt(value, 10));
|
||||
setPageNo(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -250,71 +272,83 @@ export function ApplyLogsPageClient() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={handleSearch}>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button size='sm' onClick={handleSearch}>
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetFilters}>
|
||||
<Button variant='outline' size='sm' onClick={handleResetFilters}>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <ErrorInline message={error} onRetry={() => void fetchLogs()} /> : null}
|
||||
{error ? (
|
||||
<ErrorInline message={error} onRetry={() => void fetchLogs()} />
|
||||
) : null}
|
||||
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
|
||||
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title="暂无应用日志"
|
||||
description="当前筛选条件下没有可展示的应用记录。"
|
||||
title='暂无应用日志'
|
||||
description='当前筛选条件下没有可展示的应用记录。'
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold">Node ID</TableHead>
|
||||
<TableHead className="text-xs font-semibold">版本</TableHead>
|
||||
<TableHead className="text-xs font-semibold">结果</TableHead>
|
||||
<TableHead className="text-xs font-semibold">Checksum</TableHead>
|
||||
<TableHead className="text-xs font-semibold">时间</TableHead>
|
||||
<TableHead className="text-xs font-semibold">消息</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>Node ID</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>版本</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>结果</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
Checksum
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>时间</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>消息</TableHead>
|
||||
<TableHead className='text-xs font-semibold text-right'>
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className="border-dashed hover:bg-muted/10 transition-colors align-top"
|
||||
className='border-dashed hover:bg-muted/10 transition-colors align-top'
|
||||
>
|
||||
<TableCell className="text-xs font-medium">{log.node_id}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{log.version}</TableCell>
|
||||
<TableCell className='text-xs font-medium'>
|
||||
{log.node_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{log.version}
|
||||
</TableCell>
|
||||
<TableCell>{getResultBadge(log.result)}</TableCell>
|
||||
<TableCell
|
||||
className="text-xs font-mono text-muted-foreground"
|
||||
className='text-xs font-mono text-muted-foreground'
|
||||
title={log.checksum}
|
||||
>
|
||||
{truncateHash(log.checksum)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-56">
|
||||
<div className="line-clamp-2 break-words">{log.message || "—"}</div>
|
||||
<TableCell className='text-xs text-muted-foreground max-w-56'>
|
||||
<div className='line-clamp-2 break-words'>
|
||||
{log.message || '—'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => {
|
||||
setSelectedLog(log)
|
||||
setDetailOpen(true)
|
||||
setSelectedLog(log);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3 mr-1" />
|
||||
<Eye className='size-3 mr-1' />
|
||||
详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -326,22 +360,22 @@ export function ApplyLogsPageClient() {
|
||||
</div>
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={current <= 1}
|
||||
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={totalPage === 0 || current >= totalPage}
|
||||
onClick={() =>
|
||||
setPageNo((prev) =>
|
||||
@@ -366,24 +400,25 @@ export function ApplyLogsPageClient() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认清空应用日志?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
该操作将删除全部 {total} 条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
|
||||
该操作将删除全部 {total}{' '}
|
||||
条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleaning}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={cleaning}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void handleCleanupLogs()
|
||||
event.preventDefault();
|
||||
void handleCleanupLogs();
|
||||
}}
|
||||
>
|
||||
{cleaning ? "清空中..." : "确认清空"}
|
||||
{cleaning ? '清空中...' : '确认清空'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {Suspense} from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import {ApplyLogsPageClient} from './page-client';
|
||||
import { ApplyLogsPageClient } from './page-client';
|
||||
|
||||
function ApplyLogsPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<Skeleton className='h-8 w-40' />
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-16 w-full' />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
<Skeleton className='h-96 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,4 +25,4 @@ export default function ApplyLogsPage() {
|
||||
<ApplyLogsPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {FileKey, Plus, RefreshCw, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FileKey, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -15,21 +15,30 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {TlsCertificateService} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type { TlsCertificateItem } from '@/lib/services/openflare';
|
||||
import { TlsCertificateService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {CertificateApplyDialog} from '../websites/components/certificate-apply-dialog';
|
||||
import {CertificateDetailDialog} from '../websites/components/certificate-detail-dialog';
|
||||
import {CertificateEditorDialog} from '../websites/components/certificate-editor-dialog';
|
||||
import {CertificateImportDialog} from '../websites/components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from '../websites/components/status-badge';
|
||||
import {getCertificateStatus, getErrorMessage} from '../websites/components/website-utils';
|
||||
import { CertificateApplyDialog } from '../websites/components/certificate-apply-dialog';
|
||||
import { CertificateDetailDialog } from '../websites/components/certificate-detail-dialog';
|
||||
import { CertificateEditorDialog } from '../websites/components/certificate-editor-dialog';
|
||||
import { CertificateImportDialog } from '../websites/components/certificate-import-dialog';
|
||||
import { WebsiteStatusBadge } from '../websites/components/status-badge';
|
||||
import {
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
} from '../websites/components/website-utils';
|
||||
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
|
||||
@@ -41,9 +50,14 @@ export default function CertificatesPage() {
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<TlsCertificateItem | null>(null);
|
||||
const [selectedCertificateId, setSelectedCertificateId] = useState<number | null>(null);
|
||||
const [applyCertificate, setApplyCertificate] = useState<TlsCertificateItem | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<TlsCertificateItem | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedCertificateId, setSelectedCertificateId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [applyCertificate, setApplyCertificate] =
|
||||
useState<TlsCertificateItem | null>(null);
|
||||
const [applyMode, setApplyMode] = useState<CertificateApplyMode>('edit-acme');
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
@@ -56,7 +70,7 @@ export default function CertificatesPage() {
|
||||
onSuccess: async () => {
|
||||
toast.success('证书已删除');
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
@@ -65,7 +79,7 @@ export default function CertificatesPage() {
|
||||
mutationFn: (id: number) => TlsCertificateService.renew(id),
|
||||
onSuccess: async (cert) => {
|
||||
toast.success(`证书 ${cert.name} 续期任务已提交`);
|
||||
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
@@ -87,85 +101,103 @@ export default function CertificatesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileKey className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">证书</h1>
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileKey className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>证书</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void queryClient.invalidateQueries({queryKey: certificatesQueryKey})}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: certificatesQueryKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="size-3.5 mr-1" />
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
刷新证书
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
导入证书
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setApplyOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setApplyOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
申请证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">证书列表</CardTitle>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base font-semibold'>证书列表</CardTitle>
|
||||
<CardDescription>
|
||||
展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{certificatesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={FileKey} description="加载证书列表中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={FileKey}
|
||||
description='加载证书列表中...'
|
||||
/>
|
||||
) : certificatesQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(certificatesQuery.error)}
|
||||
onRetry={() => void certificatesQuery.refetch()}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
) : certificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description="暂无证书,点击右上角「导入证书」或「申请证书」开始录入。"
|
||||
description='暂无证书,点击右上角「导入证书」或「申请证书」开始录入。'
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{certificates.map((certificate) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={certificate.id}
|
||||
className="rounded-lg border bg-card px-4 py-3"
|
||||
className='rounded-lg border bg-card px-4 py-3'
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">{certificate.name}</p>
|
||||
<WebsiteStatusBadge label={status.label} tone={status.tone} />
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='text-sm font-semibold'>
|
||||
{certificate.name}
|
||||
</p>
|
||||
<WebsiteStatusBadge
|
||||
label={status.label}
|
||||
tone={status.tone}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground space-y-0.5">
|
||||
<div className='text-xs leading-5 text-muted-foreground space-y-0.5'>
|
||||
<p>生效:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>
|
||||
来源:
|
||||
{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}
|
||||
{certificate.provider === 'acme'
|
||||
? 'ACME 申请'
|
||||
: '手动上传'}
|
||||
</p>
|
||||
{certificate.apply_status === 'applying' ? (
|
||||
<p className="text-blue-600">
|
||||
<p className='text-blue-600'>
|
||||
状态:
|
||||
{certificate.provider === 'upload'
|
||||
? '转换申请中...'
|
||||
@@ -173,9 +205,11 @@ export default function CertificatesPage() {
|
||||
</p>
|
||||
) : null}
|
||||
{certificate.apply_status === 'error' ? (
|
||||
<p className="text-destructive">
|
||||
<p className='text-destructive'>
|
||||
状态:
|
||||
{certificate.provider === 'upload' ? '转换失败' : '申请失败'}
|
||||
{certificate.provider === 'upload'
|
||||
? '转换失败'
|
||||
: '申请失败'}
|
||||
({certificate.apply_message})
|
||||
</p>
|
||||
) : null}
|
||||
@@ -183,11 +217,11 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setDetailOpen(true);
|
||||
@@ -196,18 +230,18 @@ export default function CertificatesPage() {
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => handleOpenEditor(certificate)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{certificate.provider === 'acme' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => renewMutation.mutate(certificate.id)}
|
||||
>
|
||||
@@ -215,12 +249,12 @@ export default function CertificatesPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs text-destructive'
|
||||
onClick={() => setDeleteTarget(certificate)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
<Trash2 className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +325,9 @@ export default function CertificatesPage() {
|
||||
certificateId={selectedCertificateId}
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
onSaved={(certificate) => toast.success(`证书 ${certificate.name} 已更新`)}
|
||||
onSaved={(certificate) =>
|
||||
toast.success(`证书 ${certificate.name} 已更新`)
|
||||
}
|
||||
onConvert={(certificate) => {
|
||||
setEditorOpen(false);
|
||||
setApplyMode('convert-upload');
|
||||
@@ -314,8 +350,10 @@ export default function CertificatesPage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
@@ -324,4 +362,4 @@ export default function CertificatesPage() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {CapacityTrendPoint} from '@/lib/services/openflare';
|
||||
|
||||
import {formatPercent, formatTrendHour} from './dashboard-utils';
|
||||
@@ -22,14 +16,16 @@ export function CapacityTrendChart({
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
labels={points.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
@@ -51,4 +47,4 @@ export function CapacityTrendChart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ export function formatTrendHour(value: string) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,682 +1,172 @@
|
||||
{
|
||||
"Afghanistan": [
|
||||
65.99254367483304,
|
||||
33.83349410470984
|
||||
],
|
||||
"Angola": [
|
||||
17.563198329790602,
|
||||
-12.334557680834592
|
||||
],
|
||||
"Albania": [
|
||||
20.035803636476235,
|
||||
41.179283471608585
|
||||
],
|
||||
"United Arab Emirates": [
|
||||
54.30345828396012,
|
||||
23.905698042805522
|
||||
],
|
||||
"Argentina": [
|
||||
-65.15670576062132,
|
||||
-35.178771590782524
|
||||
],
|
||||
"Armenia": [
|
||||
44.929379894609674,
|
||||
40.29884592895426
|
||||
],
|
||||
"France": [
|
||||
165.43817082439585,
|
||||
-21.285375782391018
|
||||
],
|
||||
"Australia": [
|
||||
134.361180723562,
|
||||
-25.574955774991995
|
||||
],
|
||||
"Austria": [
|
||||
14.10165807418182,
|
||||
47.57042831528493
|
||||
],
|
||||
"Azerbaijan": [
|
||||
47.636777782137415,
|
||||
40.3311508093757
|
||||
],
|
||||
"Burundi": [
|
||||
29.873065896828848,
|
||||
-3.3802825292327188
|
||||
],
|
||||
"Belgium": [
|
||||
4.628129604331132,
|
||||
50.646494813466056
|
||||
],
|
||||
"Benin": [
|
||||
2.323507643298733,
|
||||
9.63048313950481
|
||||
],
|
||||
"Burkina Faso": [
|
||||
-1.7757430610210276,
|
||||
12.260370131848955
|
||||
],
|
||||
"Bangladesh": [
|
||||
90.21009840315206,
|
||||
23.88793565572049
|
||||
],
|
||||
"Bulgaria": [
|
||||
25.225364036830744,
|
||||
42.77785706066581
|
||||
],
|
||||
"The Bahamas": [
|
||||
-78.02733865351972,
|
||||
24.719920319716515
|
||||
],
|
||||
"Bosnia and Herzegovina": [
|
||||
17.762461846176542,
|
||||
44.16398853722456
|
||||
],
|
||||
"Belarus": [
|
||||
28.04895281233843,
|
||||
53.53432933090482
|
||||
],
|
||||
"Belize": [
|
||||
-88.72096138346826,
|
||||
17.17073148225863
|
||||
],
|
||||
"Bolivia": [
|
||||
-64.69104972918655,
|
||||
-16.70141507507877
|
||||
],
|
||||
"Brazil": [
|
||||
-53.113858151998855,
|
||||
-10.827995867833263
|
||||
],
|
||||
"Brunei": [
|
||||
114.54229268997476,
|
||||
4.451218476864121
|
||||
],
|
||||
"Bhutan": [
|
||||
90.40562361681495,
|
||||
27.40133623508629
|
||||
],
|
||||
"Botswana": [
|
||||
23.791788508036337,
|
||||
-22.185333985163894
|
||||
],
|
||||
"Central African Republic": [
|
||||
20.490781591297914,
|
||||
6.563625165826992
|
||||
],
|
||||
"Canada": [
|
||||
-102.31777616484295,
|
||||
57.94457487676815
|
||||
],
|
||||
"Switzerland": [
|
||||
8.205812860319684,
|
||||
46.805544047201984
|
||||
],
|
||||
"Chile": [
|
||||
-71.34891661948937,
|
||||
-35.98028631773855
|
||||
],
|
||||
"China": [
|
||||
103.79804331240364,
|
||||
36.61094167680319
|
||||
],
|
||||
"Ivory Coast": [
|
||||
-5.569771898429842,
|
||||
7.626172775954684
|
||||
],
|
||||
"Cameroon": [
|
||||
12.739868035578002,
|
||||
5.692692453534944
|
||||
],
|
||||
"Democratic Republic of the Congo": [
|
||||
23.64811688254194,
|
||||
-2.8775533309965056
|
||||
],
|
||||
"Republic of the Congo": [
|
||||
15.21207418503586,
|
||||
-0.8564004333096924
|
||||
],
|
||||
"Colombia": [
|
||||
-73.07794094917405,
|
||||
3.9235618261252663
|
||||
],
|
||||
"Costa Rica": [
|
||||
-84.21742467558424,
|
||||
9.986463158070535
|
||||
],
|
||||
"Cuba": [
|
||||
-78.8954025482625,
|
||||
21.62136808001903
|
||||
],
|
||||
"Northern Cyprus": [
|
||||
33.39079188058263,
|
||||
35.225753269439686
|
||||
],
|
||||
"Cyprus": [
|
||||
33.02409208254226,
|
||||
34.91304424540028
|
||||
],
|
||||
"Czech Republic": [
|
||||
15.330767057694615,
|
||||
49.70160772219921
|
||||
],
|
||||
"Germany": [
|
||||
10.380496796525602,
|
||||
51.083853487429664
|
||||
],
|
||||
"Djibouti": [
|
||||
42.56788631653962,
|
||||
11.745988995518626
|
||||
],
|
||||
"Denmark": [
|
||||
-41.29088868109839,
|
||||
74.72535220460091
|
||||
],
|
||||
"Dominican Republic": [
|
||||
-70.48595892120278,
|
||||
18.896879062003393
|
||||
],
|
||||
"Algeria": [
|
||||
2.6175172555729325,
|
||||
28.158202786113698
|
||||
],
|
||||
"Ecuador": [
|
||||
-78.38384779532852,
|
||||
-1.4454571869341688
|
||||
],
|
||||
"Egypt": [
|
||||
29.86739912685152,
|
||||
26.49286512797635
|
||||
],
|
||||
"Eritrea": [
|
||||
38.835037148217566,
|
||||
15.359809600209143
|
||||
],
|
||||
"Spain": [
|
||||
-3.5708365129134876,
|
||||
40.391564705240064
|
||||
],
|
||||
"Estonia": [
|
||||
25.811350172874032,
|
||||
58.69303019372469
|
||||
],
|
||||
"Ethiopia": [
|
||||
39.60372599651433,
|
||||
8.621595995236198
|
||||
],
|
||||
"Finland": [
|
||||
26.28958351526606,
|
||||
64.52279699210293
|
||||
],
|
||||
"Fiji": [
|
||||
177.98415179869738,
|
||||
-17.827004078184217
|
||||
],
|
||||
"United Kingdom": [
|
||||
-36.367082569942795,
|
||||
-54.468348288665524
|
||||
],
|
||||
"Gabon": [
|
||||
11.774048593462979,
|
||||
-0.5859798536474902
|
||||
],
|
||||
"Georgia": [
|
||||
43.48650319000057,
|
||||
42.175027555818694
|
||||
],
|
||||
"Ghana": [
|
||||
-1.2150159067556041,
|
||||
7.94078880601944
|
||||
],
|
||||
"Guinea": [
|
||||
-10.943568403941383,
|
||||
10.44078010915303
|
||||
],
|
||||
"Gambia": [
|
||||
-16.21304964555284,
|
||||
13.37062695465985
|
||||
],
|
||||
"Guinea Bissau": [
|
||||
-14.929646023453557,
|
||||
12.05088889829685
|
||||
],
|
||||
"Equatorial Guinea": [
|
||||
10.481670786028316,
|
||||
1.5660796013647418
|
||||
],
|
||||
"Greece": [
|
||||
22.53435219348475,
|
||||
39.517734832602365
|
||||
],
|
||||
"Guatemala": [
|
||||
-90.35344030476544,
|
||||
15.679044168855697
|
||||
],
|
||||
"Guyana": [
|
||||
-58.978019347627985,
|
||||
4.800329726037504
|
||||
],
|
||||
"Honduras": [
|
||||
-86.65651796671759,
|
||||
14.821029518594349
|
||||
],
|
||||
"Croatia": [
|
||||
16.458685366615978,
|
||||
45.18567759026696
|
||||
],
|
||||
"Haiti": [
|
||||
-72.67866559866681,
|
||||
18.916195586860297
|
||||
],
|
||||
"Hungary": [
|
||||
19.410867145831855,
|
||||
47.168542742918525
|
||||
],
|
||||
"Indonesia": [
|
||||
101.52237404492608,
|
||||
-0.44681277438152994
|
||||
],
|
||||
"India": [
|
||||
79.58636900833257,
|
||||
22.901586798523514
|
||||
],
|
||||
"Ireland": [
|
||||
-8.117695195030898,
|
||||
53.2341315203433
|
||||
],
|
||||
"Iran": [
|
||||
54.266246557433305,
|
||||
32.57616445788996
|
||||
],
|
||||
"Iraq": [
|
||||
43.74006501790808,
|
||||
33.0341456153028
|
||||
],
|
||||
"Iceland": [
|
||||
-18.509670948264166,
|
||||
65.03034972428229
|
||||
],
|
||||
"Israel": [
|
||||
35.241573878810335,
|
||||
31.94758544099519
|
||||
],
|
||||
"Italy": [
|
||||
12.16233842748749,
|
||||
43.51281131035235
|
||||
],
|
||||
"Jamaica": [
|
||||
-77.32850498837172,
|
||||
18.172957893241634
|
||||
],
|
||||
"Jordan": [
|
||||
36.76131486689134,
|
||||
31.237145277490367
|
||||
],
|
||||
"Japan": [
|
||||
137.97352453982398,
|
||||
36.589925009528365
|
||||
],
|
||||
"Kashmir": [
|
||||
77.08733723957188,
|
||||
35.415738932286466
|
||||
],
|
||||
"Kazakhstan": [
|
||||
67.27815427190085,
|
||||
48.155989088140196
|
||||
],
|
||||
"Kenya": [
|
||||
37.795078751163835,
|
||||
0.5967194222965451
|
||||
],
|
||||
"Kyrgyzstan": [
|
||||
74.58224715420873,
|
||||
41.48363475100716
|
||||
],
|
||||
"Cambodia": [
|
||||
104.90702345739547,
|
||||
12.717932969523966
|
||||
],
|
||||
"South Korea": [
|
||||
127.87280447602005,
|
||||
36.475157799464604
|
||||
],
|
||||
"Kosovo": [
|
||||
20.851519165302378,
|
||||
42.51783231278167
|
||||
],
|
||||
"Kuwait": [
|
||||
47.566177395781736,
|
||||
29.316145203902064
|
||||
],
|
||||
"Laos": [
|
||||
103.75263811609112,
|
||||
18.494354885305256
|
||||
],
|
||||
"Lebanon": [
|
||||
35.88502277707876,
|
||||
33.91528991779158
|
||||
],
|
||||
"Liberia": [
|
||||
-9.325867879201954,
|
||||
6.453996218817175
|
||||
],
|
||||
"Libya": [
|
||||
18.008785486000946,
|
||||
27.031457363755713
|
||||
],
|
||||
"Sri Lanka": [
|
||||
80.70055059693914,
|
||||
7.649883097905021
|
||||
],
|
||||
"Lesotho": [
|
||||
28.24303757633595,
|
||||
-29.57351232727891
|
||||
],
|
||||
"Lithuania": [
|
||||
23.872793668340726,
|
||||
55.3142460584835
|
||||
],
|
||||
"Luxembourg": [
|
||||
6.070340274408408,
|
||||
49.76746140228473
|
||||
],
|
||||
"Latvia": [
|
||||
24.89496154477673,
|
||||
56.853143698564296
|
||||
],
|
||||
"Morocco": [
|
||||
-8.459192831832421,
|
||||
29.83932361976915
|
||||
],
|
||||
"Moldova": [
|
||||
28.446472517363826,
|
||||
47.2017924696845
|
||||
],
|
||||
"Madagascar": [
|
||||
46.70172536870331,
|
||||
-19.374776628372288
|
||||
],
|
||||
"Mexico": [
|
||||
-102.51849775988345,
|
||||
23.943713432682014
|
||||
],
|
||||
"Macedonia": [
|
||||
21.704642152886493,
|
||||
41.60373573782414
|
||||
],
|
||||
"Mali": [
|
||||
-3.5321833143642736,
|
||||
17.347810147640345
|
||||
],
|
||||
"Myanmar": [
|
||||
96.49588816533908,
|
||||
21.22201469728619
|
||||
],
|
||||
"Montenegro": [
|
||||
19.233365413523355,
|
||||
42.78395456212118
|
||||
],
|
||||
"Mongolia": [
|
||||
103.06423533091326,
|
||||
46.82560864032323
|
||||
],
|
||||
"Mozambique": [
|
||||
35.53173663088568,
|
||||
-17.27297477254694
|
||||
],
|
||||
"Mauritania": [
|
||||
-10.34307896458181,
|
||||
20.259793585678146
|
||||
],
|
||||
"Malawi": [
|
||||
34.288911351438394,
|
||||
-13.200363393826267
|
||||
],
|
||||
"Malaysia": [
|
||||
114.69201508943004,
|
||||
3.57724156754415
|
||||
],
|
||||
"Namibia": [
|
||||
17.208945346875193,
|
||||
-22.132302890947404
|
||||
],
|
||||
"Niger": [
|
||||
9.384787308614277,
|
||||
17.41758190683491
|
||||
],
|
||||
"Nigeria": [
|
||||
8.074235448092892,
|
||||
9.58958974174772
|
||||
],
|
||||
"Nicaragua": [
|
||||
-85.03229930746807,
|
||||
12.835548864379284
|
||||
],
|
||||
"Netherlands": [
|
||||
5.646194278986089,
|
||||
52.27734132368374
|
||||
],
|
||||
"Norway": [
|
||||
13.994247649210363,
|
||||
64.32249798565987
|
||||
],
|
||||
"Nepal": [
|
||||
83.9234099472594,
|
||||
28.243335814044734
|
||||
],
|
||||
"New Zealand": [
|
||||
170.54047059084724,
|
||||
-43.955381757717255
|
||||
],
|
||||
"Oman": [
|
||||
56.08271927344639,
|
||||
20.573738446088598
|
||||
],
|
||||
"Pakistan": [
|
||||
69.32988114530586,
|
||||
29.951585862562073
|
||||
],
|
||||
"Panama": [
|
||||
-80.05970573973497,
|
||||
8.521692369504816
|
||||
],
|
||||
"Peru": [
|
||||
-74.38850244798846,
|
||||
-9.156316252308201
|
||||
],
|
||||
"Philippines": [
|
||||
121.45754828266669,
|
||||
15.948824272939182
|
||||
],
|
||||
"Papua New Guinea": [
|
||||
144.22896759838426,
|
||||
-6.607045399379529
|
||||
],
|
||||
"Poland": [
|
||||
19.379892383012464,
|
||||
52.11963086628078
|
||||
],
|
||||
"United States of America": [
|
||||
-99.14669730710926,
|
||||
39.52689381707972
|
||||
],
|
||||
"North Korea": [
|
||||
127.21551783512186,
|
||||
40.144856985239926
|
||||
],
|
||||
"Portugal": [
|
||||
-7.977838817691485,
|
||||
39.6854900148663
|
||||
],
|
||||
"Paraguay": [
|
||||
-58.39900641582761,
|
||||
-23.223895588708068
|
||||
],
|
||||
"Qatar": [
|
||||
51.170816022848,
|
||||
25.32876608728452
|
||||
],
|
||||
"Romania": [
|
||||
24.971632366248883,
|
||||
45.84560772065747
|
||||
],
|
||||
"Russia": [
|
||||
99.05228241954488,
|
||||
61.677043847105494
|
||||
],
|
||||
"Rwanda": [
|
||||
29.91765145538139,
|
||||
-1.9953628964377972
|
||||
],
|
||||
"Western Sahara": [
|
||||
-12.218085234851758,
|
||||
24.221135708939354
|
||||
],
|
||||
"Saudi Arabia": [
|
||||
44.5342871199886,
|
||||
24.122854227726673
|
||||
],
|
||||
"Sudan": [
|
||||
29.946243665370346,
|
||||
15.99205876641883
|
||||
],
|
||||
"South Sudan": [
|
||||
30.253827921202955,
|
||||
7.307277152400154
|
||||
],
|
||||
"Senegal": [
|
||||
-14.472411019597182,
|
||||
14.338613732351318
|
||||
],
|
||||
"Solomon Islands": [
|
||||
160.156396875585,
|
||||
-9.628589831637283
|
||||
],
|
||||
"Sierra Leone": [
|
||||
-11.82013204896783,
|
||||
8.575093438325089
|
||||
],
|
||||
"El Salvador": [
|
||||
-88.86593554283397,
|
||||
13.734573052971413
|
||||
],
|
||||
"Somaliland": [
|
||||
46.25513292885051,
|
||||
9.729995248781385
|
||||
],
|
||||
"Somalia": [
|
||||
45.706539856671256,
|
||||
4.747960517798881
|
||||
],
|
||||
"Republic of Serbia": [
|
||||
20.766249889622852,
|
||||
44.19998417744864
|
||||
],
|
||||
"Suriname": [
|
||||
-55.91029765824739,
|
||||
4.126766010517478
|
||||
],
|
||||
"Slovakia": [
|
||||
19.47296461136286,
|
||||
48.71540683451534
|
||||
],
|
||||
"Slovenia": [
|
||||
14.771193461021065,
|
||||
46.10807043333343
|
||||
],
|
||||
"Sweden": [
|
||||
16.752551082675275,
|
||||
62.8354879894394
|
||||
],
|
||||
"Swaziland": [
|
||||
31.47664514492297,
|
||||
-26.563545981108437
|
||||
],
|
||||
"Syria": [
|
||||
38.50449031631095,
|
||||
35.01769370592341
|
||||
],
|
||||
"Chad": [
|
||||
18.645815630757973,
|
||||
15.32729896930035
|
||||
],
|
||||
"Togo": [
|
||||
0.949513544830056,
|
||||
8.576560980195756
|
||||
],
|
||||
"Thailand": [
|
||||
101.01060253224972,
|
||||
15.132319216387051
|
||||
],
|
||||
"Tajikistan": [
|
||||
70.98712700685866,
|
||||
38.55204366339685
|
||||
],
|
||||
"Turkmenistan": [
|
||||
59.39292286662992,
|
||||
39.1098173228857
|
||||
],
|
||||
"East Timor": [
|
||||
125.78222644041995,
|
||||
-8.858373836074785
|
||||
],
|
||||
"Trinidad and Tobago": [
|
||||
-61.270458833911896,
|
||||
10.339930045317969
|
||||
],
|
||||
"Tunisia": [
|
||||
9.546832905099203,
|
||||
34.12673069029241
|
||||
],
|
||||
"Turkey": [
|
||||
35.424986274948736,
|
||||
38.998407240706534
|
||||
],
|
||||
"United Republic of Tanzania": [
|
||||
34.79490109621088,
|
||||
-6.276146393386636
|
||||
],
|
||||
"Uganda": [
|
||||
32.36575488492427,
|
||||
1.2757695499320103
|
||||
],
|
||||
"Ukraine": [
|
||||
31.381193375918794,
|
||||
49.01345298489402
|
||||
],
|
||||
"Uruguay": [
|
||||
-56.01665573850212,
|
||||
-32.80651677440885
|
||||
],
|
||||
"Uzbekistan": [
|
||||
63.148826727968626,
|
||||
41.7502616897204
|
||||
],
|
||||
"Venezuela": [
|
||||
-66.1882174671549,
|
||||
7.118299933056809
|
||||
],
|
||||
"Vietnam": [
|
||||
106.29939096235873,
|
||||
16.63088189612042
|
||||
],
|
||||
"Vanuatu": [
|
||||
166.84228659210697,
|
||||
-15.266627310669035
|
||||
],
|
||||
"Yemen": [
|
||||
47.546351590294876,
|
||||
15.935825615412996
|
||||
],
|
||||
"South Africa": [
|
||||
25.160630055943862,
|
||||
-29.008774283210798
|
||||
],
|
||||
"Zambia": [
|
||||
27.775321721593084,
|
||||
-13.460954386993254
|
||||
],
|
||||
"Zimbabwe": [
|
||||
29.850564118889274,
|
||||
-19.00677451616593
|
||||
]
|
||||
"Afghanistan": [65.99254367483304, 33.83349410470984],
|
||||
"Angola": [17.563198329790602, -12.334557680834592],
|
||||
"Albania": [20.035803636476235, 41.179283471608585],
|
||||
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
|
||||
"Argentina": [-65.15670576062132, -35.178771590782524],
|
||||
"Armenia": [44.929379894609674, 40.29884592895426],
|
||||
"France": [165.43817082439585, -21.285375782391018],
|
||||
"Australia": [134.361180723562, -25.574955774991995],
|
||||
"Austria": [14.10165807418182, 47.57042831528493],
|
||||
"Azerbaijan": [47.636777782137415, 40.3311508093757],
|
||||
"Burundi": [29.873065896828848, -3.3802825292327188],
|
||||
"Belgium": [4.628129604331132, 50.646494813466056],
|
||||
"Benin": [2.323507643298733, 9.63048313950481],
|
||||
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
|
||||
"Bangladesh": [90.21009840315206, 23.88793565572049],
|
||||
"Bulgaria": [25.225364036830744, 42.77785706066581],
|
||||
"The Bahamas": [-78.02733865351972, 24.719920319716515],
|
||||
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
|
||||
"Belarus": [28.04895281233843, 53.53432933090482],
|
||||
"Belize": [-88.72096138346826, 17.17073148225863],
|
||||
"Bolivia": [-64.69104972918655, -16.70141507507877],
|
||||
"Brazil": [-53.113858151998855, -10.827995867833263],
|
||||
"Brunei": [114.54229268997476, 4.451218476864121],
|
||||
"Bhutan": [90.40562361681495, 27.40133623508629],
|
||||
"Botswana": [23.791788508036337, -22.185333985163894],
|
||||
"Central African Republic": [20.490781591297914, 6.563625165826992],
|
||||
"Canada": [-102.31777616484295, 57.94457487676815],
|
||||
"Switzerland": [8.205812860319684, 46.805544047201984],
|
||||
"Chile": [-71.34891661948937, -35.98028631773855],
|
||||
"China": [103.79804331240364, 36.61094167680319],
|
||||
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
|
||||
"Cameroon": [12.739868035578002, 5.692692453534944],
|
||||
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
|
||||
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
|
||||
"Colombia": [-73.07794094917405, 3.9235618261252663],
|
||||
"Costa Rica": [-84.21742467558424, 9.986463158070535],
|
||||
"Cuba": [-78.8954025482625, 21.62136808001903],
|
||||
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
|
||||
"Cyprus": [33.02409208254226, 34.91304424540028],
|
||||
"Czech Republic": [15.330767057694615, 49.70160772219921],
|
||||
"Germany": [10.380496796525602, 51.083853487429664],
|
||||
"Djibouti": [42.56788631653962, 11.745988995518626],
|
||||
"Denmark": [-41.29088868109839, 74.72535220460091],
|
||||
"Dominican Republic": [-70.48595892120278, 18.896879062003393],
|
||||
"Algeria": [2.6175172555729325, 28.158202786113698],
|
||||
"Ecuador": [-78.38384779532852, -1.4454571869341688],
|
||||
"Egypt": [29.86739912685152, 26.49286512797635],
|
||||
"Eritrea": [38.835037148217566, 15.359809600209143],
|
||||
"Spain": [-3.5708365129134876, 40.391564705240064],
|
||||
"Estonia": [25.811350172874032, 58.69303019372469],
|
||||
"Ethiopia": [39.60372599651433, 8.621595995236198],
|
||||
"Finland": [26.28958351526606, 64.52279699210293],
|
||||
"Fiji": [177.98415179869738, -17.827004078184217],
|
||||
"United Kingdom": [-36.367082569942795, -54.468348288665524],
|
||||
"Gabon": [11.774048593462979, -0.5859798536474902],
|
||||
"Georgia": [43.48650319000057, 42.175027555818694],
|
||||
"Ghana": [-1.2150159067556041, 7.94078880601944],
|
||||
"Guinea": [-10.943568403941383, 10.44078010915303],
|
||||
"Gambia": [-16.21304964555284, 13.37062695465985],
|
||||
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
|
||||
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
|
||||
"Greece": [22.53435219348475, 39.517734832602365],
|
||||
"Guatemala": [-90.35344030476544, 15.679044168855697],
|
||||
"Guyana": [-58.978019347627985, 4.800329726037504],
|
||||
"Honduras": [-86.65651796671759, 14.821029518594349],
|
||||
"Croatia": [16.458685366615978, 45.18567759026696],
|
||||
"Haiti": [-72.67866559866681, 18.916195586860297],
|
||||
"Hungary": [19.410867145831855, 47.168542742918525],
|
||||
"Indonesia": [101.52237404492608, -0.44681277438152994],
|
||||
"India": [79.58636900833257, 22.901586798523514],
|
||||
"Ireland": [-8.117695195030898, 53.2341315203433],
|
||||
"Iran": [54.266246557433305, 32.57616445788996],
|
||||
"Iraq": [43.74006501790808, 33.0341456153028],
|
||||
"Iceland": [-18.509670948264166, 65.03034972428229],
|
||||
"Israel": [35.241573878810335, 31.94758544099519],
|
||||
"Italy": [12.16233842748749, 43.51281131035235],
|
||||
"Jamaica": [-77.32850498837172, 18.172957893241634],
|
||||
"Jordan": [36.76131486689134, 31.237145277490367],
|
||||
"Japan": [137.97352453982398, 36.589925009528365],
|
||||
"Kashmir": [77.08733723957188, 35.415738932286466],
|
||||
"Kazakhstan": [67.27815427190085, 48.155989088140196],
|
||||
"Kenya": [37.795078751163835, 0.5967194222965451],
|
||||
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
|
||||
"Cambodia": [104.90702345739547, 12.717932969523966],
|
||||
"South Korea": [127.87280447602005, 36.475157799464604],
|
||||
"Kosovo": [20.851519165302378, 42.51783231278167],
|
||||
"Kuwait": [47.566177395781736, 29.316145203902064],
|
||||
"Laos": [103.75263811609112, 18.494354885305256],
|
||||
"Lebanon": [35.88502277707876, 33.91528991779158],
|
||||
"Liberia": [-9.325867879201954, 6.453996218817175],
|
||||
"Libya": [18.008785486000946, 27.031457363755713],
|
||||
"Sri Lanka": [80.70055059693914, 7.649883097905021],
|
||||
"Lesotho": [28.24303757633595, -29.57351232727891],
|
||||
"Lithuania": [23.872793668340726, 55.3142460584835],
|
||||
"Luxembourg": [6.070340274408408, 49.76746140228473],
|
||||
"Latvia": [24.89496154477673, 56.853143698564296],
|
||||
"Morocco": [-8.459192831832421, 29.83932361976915],
|
||||
"Moldova": [28.446472517363826, 47.2017924696845],
|
||||
"Madagascar": [46.70172536870331, -19.374776628372288],
|
||||
"Mexico": [-102.51849775988345, 23.943713432682014],
|
||||
"Macedonia": [21.704642152886493, 41.60373573782414],
|
||||
"Mali": [-3.5321833143642736, 17.347810147640345],
|
||||
"Myanmar": [96.49588816533908, 21.22201469728619],
|
||||
"Montenegro": [19.233365413523355, 42.78395456212118],
|
||||
"Mongolia": [103.06423533091326, 46.82560864032323],
|
||||
"Mozambique": [35.53173663088568, -17.27297477254694],
|
||||
"Mauritania": [-10.34307896458181, 20.259793585678146],
|
||||
"Malawi": [34.288911351438394, -13.200363393826267],
|
||||
"Malaysia": [114.69201508943004, 3.57724156754415],
|
||||
"Namibia": [17.208945346875193, -22.132302890947404],
|
||||
"Niger": [9.384787308614277, 17.41758190683491],
|
||||
"Nigeria": [8.074235448092892, 9.58958974174772],
|
||||
"Nicaragua": [-85.03229930746807, 12.835548864379284],
|
||||
"Netherlands": [5.646194278986089, 52.27734132368374],
|
||||
"Norway": [13.994247649210363, 64.32249798565987],
|
||||
"Nepal": [83.9234099472594, 28.243335814044734],
|
||||
"New Zealand": [170.54047059084724, -43.955381757717255],
|
||||
"Oman": [56.08271927344639, 20.573738446088598],
|
||||
"Pakistan": [69.32988114530586, 29.951585862562073],
|
||||
"Panama": [-80.05970573973497, 8.521692369504816],
|
||||
"Peru": [-74.38850244798846, -9.156316252308201],
|
||||
"Philippines": [121.45754828266669, 15.948824272939182],
|
||||
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
|
||||
"Poland": [19.379892383012464, 52.11963086628078],
|
||||
"United States of America": [-99.14669730710926, 39.52689381707972],
|
||||
"North Korea": [127.21551783512186, 40.144856985239926],
|
||||
"Portugal": [-7.977838817691485, 39.6854900148663],
|
||||
"Paraguay": [-58.39900641582761, -23.223895588708068],
|
||||
"Qatar": [51.170816022848, 25.32876608728452],
|
||||
"Romania": [24.971632366248883, 45.84560772065747],
|
||||
"Russia": [99.05228241954488, 61.677043847105494],
|
||||
"Rwanda": [29.91765145538139, -1.9953628964377972],
|
||||
"Western Sahara": [-12.218085234851758, 24.221135708939354],
|
||||
"Saudi Arabia": [44.5342871199886, 24.122854227726673],
|
||||
"Sudan": [29.946243665370346, 15.99205876641883],
|
||||
"South Sudan": [30.253827921202955, 7.307277152400154],
|
||||
"Senegal": [-14.472411019597182, 14.338613732351318],
|
||||
"Solomon Islands": [160.156396875585, -9.628589831637283],
|
||||
"Sierra Leone": [-11.82013204896783, 8.575093438325089],
|
||||
"El Salvador": [-88.86593554283397, 13.734573052971413],
|
||||
"Somaliland": [46.25513292885051, 9.729995248781385],
|
||||
"Somalia": [45.706539856671256, 4.747960517798881],
|
||||
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
|
||||
"Suriname": [-55.91029765824739, 4.126766010517478],
|
||||
"Slovakia": [19.47296461136286, 48.71540683451534],
|
||||
"Slovenia": [14.771193461021065, 46.10807043333343],
|
||||
"Sweden": [16.752551082675275, 62.8354879894394],
|
||||
"Swaziland": [31.47664514492297, -26.563545981108437],
|
||||
"Syria": [38.50449031631095, 35.01769370592341],
|
||||
"Chad": [18.645815630757973, 15.32729896930035],
|
||||
"Togo": [0.949513544830056, 8.576560980195756],
|
||||
"Thailand": [101.01060253224972, 15.132319216387051],
|
||||
"Tajikistan": [70.98712700685866, 38.55204366339685],
|
||||
"Turkmenistan": [59.39292286662992, 39.1098173228857],
|
||||
"East Timor": [125.78222644041995, -8.858373836074785],
|
||||
"Trinidad and Tobago": [-61.270458833911896, 10.339930045317969],
|
||||
"Tunisia": [9.546832905099203, 34.12673069029241],
|
||||
"Turkey": [35.424986274948736, 38.998407240706534],
|
||||
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
|
||||
"Uganda": [32.36575488492427, 1.2757695499320103],
|
||||
"Ukraine": [31.381193375918794, 49.01345298489402],
|
||||
"Uruguay": [-56.01665573850212, -32.80651677440885],
|
||||
"Uzbekistan": [63.148826727968626, 41.7502616897204],
|
||||
"Venezuela": [-66.1882174671549, 7.118299933056809],
|
||||
"Vietnam": [106.29939096235873, 16.63088189612042],
|
||||
"Vanuatu": [166.84228659210697, -15.266627310669035],
|
||||
"Yemen": [47.546351590294876, 15.935825615412996],
|
||||
"South Africa": [25.160630055943862, -29.008774283210798],
|
||||
"Zambia": [27.775321721593084, -13.460954386993254],
|
||||
"Zimbabwe": [29.850564118889274, -19.00677451616593]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import {RankChart} from '@/components/data/rank-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type {DistributionItem, TrafficDistributions} from '@/lib/services/openflare';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {DistributionItem, TrafficDistributions,} from '@/lib/services/openflare';
|
||||
|
||||
function toRankItems(items: DistributionItem[]) {
|
||||
return items.map((item) => ({
|
||||
@@ -23,18 +17,18 @@ export function SourceDistributionChart({
|
||||
items: TrafficDistributions['source_countries'];
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">来源分布</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
聚合最近 24 小时主要来源国家。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无来源分布数据"
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无来源分布数据'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -47,10 +41,10 @@ export function StatusCodeDistributionChart({
|
||||
items: TrafficDistributions['status_codes'];
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">状态码分布</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -60,8 +54,8 @@ export function StatusCodeDistributionChart({
|
||||
...item,
|
||||
label: `HTTP ${item.label}`,
|
||||
}))}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
color='#f59e0b'
|
||||
emptyMessage='暂无状态码分布'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -74,20 +68,20 @@ export function TopDomainChart({
|
||||
items: TrafficDistributions['top_domains'];
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Top Domain</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察主要流量集中在哪些域名。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color="#34d399"
|
||||
emptyMessage="暂无域名分布"
|
||||
color='#34d399'
|
||||
emptyMessage='暂无域名分布'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,34 +15,34 @@ export function GeoDistributionList({ items }: { items: DistributionItem[] }) {
|
||||
const maxValue = sortedItems[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
|
||||
<Globe2 className='size-4 text-primary' />
|
||||
来源国家分布
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardDescription className='text-xs'>
|
||||
聚合最近 24 小时主要来源国家。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
|
||||
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
|
||||
暂无来源分布数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{sortedItems.map((item) => {
|
||||
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||
return (
|
||||
<div key={item.key} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{item.key || '未知'}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
<div key={item.key} className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between text-xs'>
|
||||
<span className='font-medium'>{item.key || '未知'}</span>
|
||||
<span className='font-mono tabular-nums text-muted-foreground'>
|
||||
{formatCompactNumber(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={ratio} className="h-1.5" />
|
||||
<Progress value={ratio} className='h-1.5' />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {Card, CardContent, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {DiskIOTrendPoint, NetworkTrendPoint} from '@/lib/services/openflare';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type {
|
||||
DiskIOTrendPoint,
|
||||
NetworkTrendPoint,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {formatBytes, formatTrendHour} from './dashboard-utils';
|
||||
import { formatBytes, formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function NetworkDiskTrendChart({
|
||||
networkPoints,
|
||||
@@ -14,16 +17,20 @@ export function NetworkDiskTrendChart({
|
||||
diskPoints: DiskIOTrendPoint[];
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">24 小时网络与磁盘趋势</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
24 小时网络与磁盘趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent className='space-y-6'>
|
||||
<TrendChart
|
||||
labels={networkPoints.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
labels={networkPoints.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
summaryScope="total"
|
||||
summaryHint="近 24 小时"
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
@@ -44,10 +51,12 @@ export function NetworkDiskTrendChart({
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
labels={diskPoints.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
labels={diskPoints.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
summaryScope="total"
|
||||
summaryHint="近 24 小时"
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
|
||||
@@ -2,13 +2,26 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import type {DashboardNodeHealth} from '@/lib/services/openflare';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { DashboardNodeHealth } from '@/lib/services/openflare';
|
||||
|
||||
import {NodeStatusBadge} from '../../nodes/components/node-status-badge';
|
||||
import { NodeStatusBadge } from '../../nodes/components/node-status-badge';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getNodeStatusLabel,
|
||||
@@ -17,7 +30,7 @@ import {
|
||||
getOpenrestyStatusTone,
|
||||
isWSConnectedLastSeen,
|
||||
} from '../../nodes/components/node-utils';
|
||||
import {formatCompactNumber, formatPercent} from './dashboard-utils';
|
||||
import { formatCompactNumber, formatPercent } from './dashboard-utils';
|
||||
|
||||
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
const sortedNodes = [...nodes].sort((left, right) => {
|
||||
@@ -38,52 +51,57 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-semibold">节点健康列表</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
按异常数量和资源压力排序。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" asChild>
|
||||
<Link href="/nodes">进入节点页</Link>
|
||||
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
|
||||
<Link href='/nodes'>进入节点页</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedNodes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
title='暂无节点'
|
||||
description='节点接入后,这里会展示系统健康与容量摘要。'
|
||||
/>
|
||||
) : (
|
||||
<div className="border border-dashed rounded-lg overflow-hidden">
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="py-2 h-8">节点</TableHead>
|
||||
<TableHead className="py-2 h-8">状态</TableHead>
|
||||
<TableHead className="py-2 h-8">CPU / 内存</TableHead>
|
||||
<TableHead className="py-2 h-8">请求 / 错误</TableHead>
|
||||
<TableHead className="py-2 h-8">活动异常</TableHead>
|
||||
<TableHead className="py-2 h-8">最近心跳</TableHead>
|
||||
<TableHead className="py-2 h-8 text-right">操作</TableHead>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='py-2 h-8'>节点</TableHead>
|
||||
<TableHead className='py-2 h-8'>状态</TableHead>
|
||||
<TableHead className='py-2 h-8'>CPU / 内存</TableHead>
|
||||
<TableHead className='py-2 h-8'>请求 / 错误</TableHead>
|
||||
<TableHead className='py-2 h-8'>活动异常</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近心跳</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedNodes.slice(0, 12).map((node) => (
|
||||
<TableRow key={node.node_id} className="border-dashed align-top">
|
||||
<TableCell className="py-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{node.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{node.node_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<TableRow
|
||||
key={node.node_id}
|
||||
className='border-dashed align-top'
|
||||
>
|
||||
<TableCell className='py-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-medium'>{node.name}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{node.node_id}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TableCell className='py-3'>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
@@ -94,29 +112,36 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-sm text-muted-foreground">
|
||||
<TableCell className='py-3 text-sm text-muted-foreground'>
|
||||
{formatPercent(node.cpu_usage_percent)} /{' '}
|
||||
{formatPercent(node.memory_usage_percent)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-sm text-muted-foreground">
|
||||
<TableCell className='py-3 text-sm text-muted-foreground'>
|
||||
{formatCompactNumber(node.request_count)} /{' '}
|
||||
{formatCompactNumber(node.error_count)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<TableCell className='py-3'>
|
||||
<NodeStatusBadge
|
||||
label={String(node.active_event_count)}
|
||||
tone={node.active_event_count > 0 ? 'danger' : 'success'}
|
||||
tone={
|
||||
node.active_event_count > 0 ? 'danger' : 'success'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs text-muted-foreground">
|
||||
<TableCell className='py-3 text-xs text-muted-foreground'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: node.last_seen_at
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无'}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-right">
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs" asChild>
|
||||
<TableCell className='py-3 text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import {RankChart} from '@/components/data/rank-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type {DashboardNodeHealth} from '@/lib/services/openflare';
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { DashboardNodeHealth } from '@/lib/services/openflare';
|
||||
|
||||
function buildNodeRankItems(
|
||||
nodes: DashboardNodeHealth[],
|
||||
@@ -31,25 +26,25 @@ function buildNodeRankItems(
|
||||
}));
|
||||
}
|
||||
|
||||
export function NodeRankPanel({nodes}: {nodes: DashboardNodeHealth[]}) {
|
||||
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Top 节点榜单</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<CardContent className='grid gap-6'>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase">
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
流量最高节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(nodes, (node) => node.request_count)}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无流量榜单"
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无流量榜单'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase">
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
容量压力节点
|
||||
</p>
|
||||
<RankChart
|
||||
@@ -62,12 +57,12 @@ export function NodeRankPanel({nodes}: {nodes: DashboardNodeHealth[]}) {
|
||||
),
|
||||
),
|
||||
)}
|
||||
color="#ef4444"
|
||||
color='#ef4444'
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage="暂无容量压力数据"
|
||||
emptyMessage='暂无容量压力数据'
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import {Activity, Cpu, Gauge, Server, Users} from 'lucide-react';
|
||||
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardHeader} from '@/components/ui/card';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import type {DashboardCapacity, DashboardSummary, DashboardTraffic,} from '@/lib/services/openflare';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type {
|
||||
DashboardCapacity,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {formatCompactNumber, formatPercent} from './dashboard-utils';
|
||||
import { formatCompactNumber, formatPercent } from './dashboard-utils';
|
||||
|
||||
export function DashboardStatCards({
|
||||
summary,
|
||||
@@ -23,17 +27,19 @@ export function DashboardStatCards({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<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">24h 请求量</span>
|
||||
<Activity className="size-4 text-primary" />
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<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'>
|
||||
24h 请求量
|
||||
</span>
|
||||
<Activity className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='text-2xl font-semibold tracking-tight'>
|
||||
{formatCompactNumber(traffic.request_count)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
窗口UV(估) {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
|
||||
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
|
||||
{traffic.estimated_qps.toFixed(2)}
|
||||
@@ -41,48 +47,55 @@ export function DashboardStatCards({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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" />
|
||||
<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">
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='text-2xl font-semibold tracking-tight'>
|
||||
{formatPercent(capacity.average_cpu_usage_percent)}
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
<span className='ml-2 text-sm font-normal text-muted-foreground'>
|
||||
CPU
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={capacity.average_cpu_usage_percent} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
平均内存 {formatPercent(capacity.average_memory_usage_percent)} · 高负载节点 CPU{' '}
|
||||
{capacity.high_cpu_nodes} / 内存 {capacity.high_memory_nodes} / 存储{' '}
|
||||
{capacity.high_storage_nodes}
|
||||
<Progress
|
||||
value={capacity.average_cpu_usage_percent}
|
||||
className='h-1.5'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
平均内存 {formatPercent(capacity.average_memory_usage_percent)} ·
|
||||
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '}
|
||||
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none md:col-span-2 xl:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">节点概览</span>
|
||||
<Server className="size-4 text-primary" />
|
||||
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
节点概览
|
||||
</span>
|
||||
<Server className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">
|
||||
<CardContent className='space-y-2'>
|
||||
<div className='text-2xl font-semibold tracking-tight'>
|
||||
{summary.online_nodes}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
<span className='text-sm font-normal text-muted-foreground'>
|
||||
{' '}
|
||||
/ {summary.total_nodes} 在线
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={onlineRate} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-3" />
|
||||
<Progress value={onlineRate} className='h-1.5' />
|
||||
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Users className='size-3' />
|
||||
待接入 {summary.pending_nodes}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Gauge className="size-3" />
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Gauge className='size-3' />
|
||||
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {TrafficTrendPoint} from '@/lib/services/openflare';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type { TrafficTrendPoint } from '@/lib/services/openflare';
|
||||
|
||||
import {formatTrendHour} from './dashboard-utils';
|
||||
import { formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function TrafficTrendChart({
|
||||
points,
|
||||
@@ -16,16 +22,18 @@ export function TrafficTrendChart({
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
summaryScope="total"
|
||||
summaryHint="近 24 小时"
|
||||
labels={points.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||
import type {EChartsCoreOption} from 'echarts/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import {MapChart, ScatterChart} from 'echarts/charts';
|
||||
import {GeoComponent, TooltipComponent} from 'echarts/components';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {useTheme} from 'next-themes';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import { MapChart, ScatterChart } from 'echarts/charts';
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/layout/empty';
|
||||
import type {DashboardNodeHealth, DistributionItem} from '@/lib/services/openflare';
|
||||
import { EmptyState } from '@/components/layout/empty';
|
||||
import type {
|
||||
DashboardNodeHealth,
|
||||
DistributionItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {getNodeStatusLabel, getOpenrestyStatusLabel,} from '../../nodes/components/node-utils';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getOpenrestyStatusLabel,
|
||||
} from '../../nodes/components/node-utils';
|
||||
import countryCentroidsJson from './data/country-centroids.json';
|
||||
import worldGeoJson from './data/world-geo.json';
|
||||
|
||||
@@ -133,7 +139,9 @@ const countryCentroids = countryCentroidsJson as Record<string, number[]>;
|
||||
|
||||
type CountryCentroid = [number, number];
|
||||
|
||||
function toCountryCentroid(coords: number[] | undefined): CountryCentroid | null {
|
||||
function toCountryCentroid(
|
||||
coords: number[] | undefined,
|
||||
): CountryCentroid | null {
|
||||
if (!coords || coords.length < 2) {
|
||||
return null;
|
||||
}
|
||||
@@ -235,7 +243,7 @@ export function WorldStageMap({
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const {resolvedTheme} = useTheme();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const router = useRouter();
|
||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -268,7 +276,7 @@ export function WorldStageMap({
|
||||
setContainerSize((previous) =>
|
||||
previous.width === nextWidth && previous.height === nextHeight
|
||||
? previous
|
||||
: {width: nextWidth, height: nextHeight},
|
||||
: { width: nextWidth, height: nextHeight },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -423,8 +431,7 @@ export function WorldStageMap({
|
||||
}, [containerSize]);
|
||||
|
||||
const computedLayoutSize = useMemo(
|
||||
() =>
|
||||
`${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
|
||||
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
|
||||
[responsiveMapScale],
|
||||
);
|
||||
const computedZoom = useMemo(
|
||||
@@ -442,9 +449,7 @@ export function WorldStageMap({
|
||||
backgroundColor: isDark
|
||||
? 'rgba(24,24,27,0.96)'
|
||||
: 'rgba(255,255,255,0.98)',
|
||||
borderColor: isDark
|
||||
? 'rgba(63,63,70,0.8)'
|
||||
: 'rgba(228,228,231,0.9)',
|
||||
borderColor: isDark ? 'rgba(63,63,70,0.8)' : 'rgba(228,228,231,0.9)',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: isDark ? '#fafafa' : '#18181b',
|
||||
@@ -577,7 +582,7 @@ export function WorldStageMap({
|
||||
|
||||
if (!mapReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
@@ -593,16 +598,16 @@ export function WorldStageMap({
|
||||
const chartReady = containerSize.width > 0 && containerSize.height > 0;
|
||||
|
||||
return (
|
||||
<div ref={chartContainerRef} className="h-full min-h-0 w-full">
|
||||
<div ref={chartContainerRef} className='h-full min-h-0 w-full'>
|
||||
{chartReady ? (
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={mapOption}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
opts={{renderer: 'canvas'}}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
onEvents={{
|
||||
click: (params: {data?: MapNodeDatum}) => {
|
||||
click: (params: { data?: MapNodeDatum }) => {
|
||||
if (params.data?.route) {
|
||||
router.push(params.data.route);
|
||||
}
|
||||
@@ -614,11 +619,11 @@ export function WorldStageMap({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title="全球地图加载中"
|
||||
description="正在测量地图容器尺寸..."
|
||||
iconSize="sm"
|
||||
title='全球地图加载中'
|
||||
description='正在测量地图容器尺寸...'
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import {type ComponentType, useEffect, useRef, useState} from 'react';
|
||||
import {Activity, Cpu, Globe2, HardDrive, MemoryStick, Network, Server, ShieldCheck,} from 'lucide-react';
|
||||
import { type ComponentType, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Network,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {EmptyState} from '@/components/layout/empty';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import { EmptyState } from '@/components/layout/empty';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type {
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
@@ -15,20 +30,20 @@ import type {
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/lib/services/openflare';
|
||||
import {cn} from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {formatCompactNumber, formatPercent} from './dashboard-utils';
|
||||
import { formatCompactNumber, formatPercent } from './dashboard-utils';
|
||||
|
||||
const WorldStageMap = dynamic(
|
||||
() => import('./world-stage-map').then((module) => module.WorldStageMap),
|
||||
{ssr: false},
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{dot: 'bg-blue-500', label: '来源'},
|
||||
{dot: 'bg-emerald-500', label: '正常'},
|
||||
{dot: 'bg-amber-500', label: '承压'},
|
||||
{dot: 'bg-destructive', label: '异常'},
|
||||
{ dot: 'bg-blue-500', label: '来源' },
|
||||
{ dot: 'bg-emerald-500', label: '正常' },
|
||||
{ dot: 'bg-amber-500', label: '承压' },
|
||||
{ dot: 'bg-destructive', label: '异常' },
|
||||
] as const;
|
||||
|
||||
function SummaryMetric({
|
||||
@@ -42,7 +57,7 @@ function SummaryMetric({
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
icon: ComponentType<{className?: string}>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -53,17 +68,17 @@ function SummaryMetric({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs text-muted-foreground">{label}</span>
|
||||
<Icon className="size-3.5 shrink-0 text-primary/60" />
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='truncate text-xs text-muted-foreground'>{label}</span>
|
||||
<Icon className='size-3.5 shrink-0 text-primary/60' />
|
||||
</div>
|
||||
<span className="text-xl font-semibold tabular-nums leading-none tracking-tight">
|
||||
<span className='text-xl font-semibold tabular-nums leading-none tracking-tight'>
|
||||
{value}
|
||||
</span>
|
||||
{typeof progress === 'number' ? (
|
||||
<Progress value={progress} className="h-1" />
|
||||
<Progress value={progress} className='h-1' />
|
||||
) : null}
|
||||
<span className="truncate text-[10px] text-muted-foreground">{hint}</span>
|
||||
<span className='truncate text-[10px] text-muted-foreground'>{hint}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,24 +93,24 @@ function DetailMetric({
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
icon: ComponentType<{className?: string}>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 rounded-lg border border-dashed bg-background/60 px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[11px] text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-lg font-semibold tabular-nums leading-none tracking-tight">
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border border-dashed bg-background/60 px-3 py-2.5'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-[11px] text-muted-foreground'>{label}</p>
|
||||
<p className='mt-1 text-lg font-semibold tabular-nums leading-none tracking-tight'>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Icon className='size-3.5 shrink-0 text-muted-foreground' />
|
||||
</div>
|
||||
{typeof progress === 'number' ? (
|
||||
<Progress value={progress} className="h-1" />
|
||||
<Progress value={progress} className='h-1' />
|
||||
) : null}
|
||||
<p className="truncate text-[10px] text-muted-foreground">{hint}</p>
|
||||
<p className='truncate text-[10px] text-muted-foreground'>{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +149,7 @@ export function WorldStage({
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{rootMargin: '120px 0px'},
|
||||
{ rootMargin: '120px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(mapViewport);
|
||||
@@ -223,29 +238,29 @@ export function WorldStage({
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-dashed shadow-none">
|
||||
<CardHeader className="gap-3 space-y-0 pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Globe2 className="size-4 shrink-0 text-primary" />
|
||||
<Card className='overflow-hidden border-dashed shadow-none'>
|
||||
<CardHeader className='gap-3 space-y-0 pb-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-x-4 gap-y-3'>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Globe2 className='size-4 shrink-0 text-primary' />
|
||||
全球态势板
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-[10px] font-normal text-muted-foreground"
|
||||
variant='outline'
|
||||
className='ml-1 text-[10px] font-normal text-muted-foreground'
|
||||
>
|
||||
{mapModeLabel}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
<CardDescription className='mt-1 text-xs'>
|
||||
汇总节点分布、来源国家热度与最近窗口运行状态。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5">
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground"
|
||||
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', item.dot)} />
|
||||
{item.label}
|
||||
@@ -255,34 +270,37 @@ export function WorldStage({
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4 pt-0">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<CardContent className='flex flex-col gap-4 pt-0'>
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{headlineMetrics.map((metric) => (
|
||||
<SummaryMetric key={metric.label} {...metric} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem] xl:items-stretch">
|
||||
<div className="flex min-w-0 items-center justify-center rounded-lg border border-dashed bg-muted/10 p-3">
|
||||
<div className='grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem] xl:items-stretch'>
|
||||
<div className='flex min-w-0 items-center justify-center rounded-lg border border-dashed bg-muted/10 p-3'>
|
||||
<div
|
||||
ref={mapViewportRef}
|
||||
className="relative aspect-[5/3] min-h-[260px] w-full max-w-[720px] overflow-hidden rounded-md bg-background/60 sm:min-h-0"
|
||||
className='relative aspect-[5/3] min-h-[260px] w-full max-w-[720px] overflow-hidden rounded-md bg-background/60 sm:min-h-0'
|
||||
>
|
||||
{shouldRenderMap ? (
|
||||
<WorldStageMap nodes={nodes} sourceCountries={sourceCountries} />
|
||||
<WorldStageMap
|
||||
nodes={nodes}
|
||||
sourceCountries={sourceCountries}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<div className='flex h-full items-center justify-center px-4'>
|
||||
<EmptyState
|
||||
title="地图准备中"
|
||||
description="进入可视区域后加载"
|
||||
iconSize="sm"
|
||||
title='地图准备中'
|
||||
description='进入可视区域后加载'
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-2 z-10">
|
||||
<p className="rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm">
|
||||
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
|
||||
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
|
||||
暂无节点,接入后将展示地理分布
|
||||
</p>
|
||||
</div>
|
||||
@@ -290,7 +308,7 @@ export function WorldStage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className='grid gap-2 sm:grid-cols-2 xl:grid-cols-1'>
|
||||
{detailMetrics.map((metric) => (
|
||||
<DetailMetric key={metric.label} {...metric} />
|
||||
))}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import {Card, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function PlaceholderPage({title, icon: Icon}: PlaceholderPageProps) {
|
||||
export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">开发中</CardTitle>
|
||||
<CardTitle className='text-base'>开发中</CardTitle>
|
||||
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect} from "react"
|
||||
import {zodResolver} from "@hookform/resolvers/zod"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {useForm} from "react-hook-form"
|
||||
import {z} from "zod"
|
||||
import {useEffect} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -14,22 +14,22 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
|
||||
const cleanupSchema = z.object({
|
||||
keepCount: z.number().int().min(3, "最少保留 3 个历史快照"),
|
||||
})
|
||||
keepCount: z.number().int().min(3, '最少保留 3 个历史快照'),
|
||||
});
|
||||
|
||||
type CleanupFormValues = z.infer<typeof cleanupSchema>
|
||||
type CleanupFormValues = z.infer<typeof cleanupSchema>;
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (keepCount: number) => void
|
||||
loading: boolean
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (keepCount: number) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function CleanupDialog({
|
||||
@@ -41,17 +41,17 @@ export function CleanupDialog({
|
||||
const form = useForm<CleanupFormValues>({
|
||||
resolver: zodResolver(cleanupSchema),
|
||||
defaultValues: { keepCount: 10 },
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ keepCount: 10 })
|
||||
form.reset({ keepCount: 10 });
|
||||
}
|
||||
}, [form, open])
|
||||
}, [form, open]);
|
||||
|
||||
const handleConfirm = form.handleSubmit((values) => {
|
||||
onConfirm(values.keepCount)
|
||||
})
|
||||
onConfirm(values.keepCount);
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -63,35 +63,43 @@ export function CleanupDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keepCount">保留最近快照个数</Label>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='keepCount'>保留最近快照个数</Label>
|
||||
<Input
|
||||
id="keepCount"
|
||||
type="number"
|
||||
id='keepCount'
|
||||
type='number'
|
||||
min={3}
|
||||
disabled={loading}
|
||||
{...form.register("keepCount", { valueAsNumber: true })}
|
||||
{...form.register('keepCount', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">默认为 10 个,最少需保留 3 个。</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
默认为 10 个,最少需保留 3 个。
|
||||
</p>
|
||||
{form.formState.errors.keepCount ? (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.keepCount.message}</p>
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.keepCount.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={() => void handleConfirm()} disabled={loading}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
</>
|
||||
) : (
|
||||
"确认清理"
|
||||
'确认清理'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,50 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {Loader2} from "lucide-react"
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import type {ConfigDiffResult} from "@/lib/services/openflare"
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import type {ConfigDiffResult} from '@/lib/services/openflare';
|
||||
|
||||
interface DiffDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
diff: ConfigDiffResult | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
diff: ConfigDiffResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function DiffChipList({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold">{title}</p>
|
||||
<Badge variant="outline" className="text-[10px] rounded-full">
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='text-xs font-semibold'>{title}</p>
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
{items.length} 项
|
||||
</Badge>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{items.map((item) => (
|
||||
<Badge key={item} variant="secondary" className="text-[10px] font-normal">
|
||||
<Badge
|
||||
key={item}
|
||||
variant='secondary'
|
||||
className='text-[10px] font-normal'
|
||||
>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">无变更</p>
|
||||
<p className='text-xs text-muted-foreground'>无变更</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionValue(value: string) {
|
||||
return value === "" ? "空" : value
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
export function DiffDialog({
|
||||
@@ -52,7 +56,7 @@ export function DiffDialog({
|
||||
}: DiffDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>配置差异</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -61,69 +65,98 @@ export function DiffDialog({
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载差异数据...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className='text-sm text-destructive'>{error}</p>
|
||||
) : diff ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">激活版本</p>
|
||||
<p className="mt-1 text-sm font-medium">{diff.active_version || "无"}</p>
|
||||
<div className='space-y-5'>
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
激活版本
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{diff.active_version || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">新增域名</p>
|
||||
<p className="mt-1 text-lg font-semibold text-emerald-600">{diff.added_domains.length}</p>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
新增域名
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-emerald-600'>
|
||||
{diff.added_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">删除域名</p>
|
||||
<p className="mt-1 text-lg font-semibold text-amber-600">{diff.removed_domains.length}</p>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
删除域名
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-amber-600'>
|
||||
{diff.removed_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">修改域名</p>
|
||||
<p className="mt-1 text-lg font-semibold text-blue-600">{diff.modified_domains.length}</p>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
修改域名
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-blue-600'>
|
||||
{diff.modified_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<DiffChipList title="新增域名" items={diff.added_domains} />
|
||||
<DiffChipList title="删除域名" items={diff.removed_domains} />
|
||||
<DiffChipList title="修改域名" items={diff.modified_domains} />
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<DiffChipList title='新增域名' items={diff.added_domains} />
|
||||
<DiffChipList title='删除域名' items={diff.removed_domains} />
|
||||
<DiffChipList title='修改域名' items={diff.modified_domains} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={diff.main_config_changed ? "destructive" : "outline"}>
|
||||
主配置 {diff.main_config_changed ? "已变化" : "无变化"}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant={diff.main_config_changed ? 'destructive' : 'outline'}
|
||||
>
|
||||
主配置 {diff.main_config_changed ? '已变化' : '无变化'}
|
||||
</Badge>
|
||||
<Badge variant={diff.waf_config_changed ? "destructive" : "outline"}>
|
||||
WAF {diff.waf_config_changed ? "已变化" : "无变化"}
|
||||
<Badge
|
||||
variant={diff.waf_config_changed ? 'destructive' : 'outline'}
|
||||
>
|
||||
WAF {diff.waf_config_changed ? '已变化' : '无变化'}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
网站数 {diff.active_website_count} → {diff.current_website_count}
|
||||
<Badge variant='outline'>
|
||||
网站数 {diff.active_website_count} →{' '}
|
||||
{diff.current_website_count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{diff.changed_option_details.length > 0 ? (
|
||||
<div className="border border-dashed rounded-lg overflow-hidden">
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<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>
|
||||
<TableHead className="text-xs font-semibold">待发布值</TableHead>
|
||||
<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>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
待发布值
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{diff.changed_option_details.map((item) => (
|
||||
<TableRow key={item.key} className="border-dashed">
|
||||
<TableCell className="text-xs font-medium">{item.key}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
<TableRow key={item.key} className='border-dashed'>
|
||||
<TableCell className='text-xs font-medium'>
|
||||
{item.key}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs font-mono text-muted-foreground'>
|
||||
{renderOptionValue(item.previous_value)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
<TableCell className='text-xs font-mono text-muted-foreground'>
|
||||
{renderOptionValue(item.current_value)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -132,11 +165,13 @@ export function DiffDialog({
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">当前无 OpenResty 参数变化。</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前无 OpenResty 参数变化。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {Loader2} from "lucide-react"
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
|
||||
import type {ConfigPreviewResult} from "@/lib/services/openflare"
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import type { ConfigPreviewResult } from '@/lib/services/openflare';
|
||||
|
||||
interface PreviewSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
preview: ConfigPreviewResult | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
publishing: boolean
|
||||
canPublish: boolean
|
||||
onPublish: () => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
preview: ConfigPreviewResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
publishing: boolean;
|
||||
canPublish: boolean;
|
||||
onPublish: () => void;
|
||||
}
|
||||
|
||||
export function PreviewSheet({
|
||||
@@ -31,65 +38,86 @@ export function PreviewSheet({
|
||||
}: PreviewSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0">
|
||||
<SheetHeader className="px-5 py-4 border-b">
|
||||
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>发布预览</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看待发布配置的渲染结果与支持文件。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载预览...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className='text-sm text-destructive'>{error}</p>
|
||||
) : preview ? (
|
||||
<div className="space-y-5 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="rounded-full text-[10px]">
|
||||
<div className='space-y-5 pb-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
规则 {preview.route_count} 条
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full text-[10px]">
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
网站 {preview.website_count} 个
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full text-[10px] font-mono">
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='rounded-full text-[10px] font-mono'
|
||||
>
|
||||
{preview.checksum.slice(0, 16)}...
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold">主配置</p>
|
||||
<CodeBlock code={preview.main_config} language="nginx" className="my-0 text-xs" />
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>主配置</p>
|
||||
<CodeBlock
|
||||
code={preview.main_config}
|
||||
language='nginx'
|
||||
className='my-0 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold">路由配置</p>
|
||||
<CodeBlock code={preview.rendered_config} language="nginx" className="my-0 text-xs" />
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>路由配置</p>
|
||||
<CodeBlock
|
||||
code={preview.rendered_config}
|
||||
language='nginx'
|
||||
className='my-0 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{preview.support_files.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold">支持文件 ({preview.support_files.length})</p>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>
|
||||
支持文件 ({preview.support_files.length})
|
||||
</p>
|
||||
{preview.support_files.map((file) => (
|
||||
<details
|
||||
key={file.path}
|
||||
className="rounded-lg border border-dashed px-3 py-2"
|
||||
className='rounded-lg border border-dashed px-3 py-2'
|
||||
>
|
||||
<summary className="cursor-pointer text-xs font-medium">{file.path}</summary>
|
||||
<CodeBlock code={file.content} language="text" className="my-2 text-xs" />
|
||||
<summary className='cursor-pointer text-xs font-medium'>
|
||||
{file.path}
|
||||
</summary>
|
||||
<CodeBlock
|
||||
code={file.content}
|
||||
language='text'
|
||||
className='my-2 text-xs'
|
||||
/>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">当前发布不需要额外支持文件。</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前发布不需要额外支持文件。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canPublish ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前规则与已激活版本一致,无法重复发布。
|
||||
</p>
|
||||
) : null}
|
||||
@@ -97,22 +125,29 @@ export function PreviewSheet({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={publishing}>
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={publishing}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={onPublish} disabled={!canPublish || publishing || loading}>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
disabled={!canPublish || publishing || loading}
|
||||
>
|
||||
{publishing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
发布中...
|
||||
</>
|
||||
) : (
|
||||
"确认发布"
|
||||
'确认发布'
|
||||
)}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
|
||||
import {type ConfigVersionDetail, ConfigVersionService, type ConfigVersionSummary,} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
type ConfigVersionDetail,
|
||||
ConfigVersionService,
|
||||
type ConfigVersionSummary,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface VersionSnapshotSheetProps {
|
||||
version: ConfigVersionSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
version: ConfigVersionSummary | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function VersionSnapshotSheet({
|
||||
@@ -22,97 +33,112 @@ export function VersionSnapshotSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VersionSnapshotSheetProps) {
|
||||
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !version) {
|
||||
setDetail(null)
|
||||
return
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
ConfigVersionService.getById(version.id)
|
||||
.then((data) => {
|
||||
if (!cancelled) setDetail(data)
|
||||
if (!cancelled) setDetail(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
toast.error("加载版本快照失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('加载版本快照失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, version])
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, version]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0">
|
||||
<SheetHeader className="px-5 py-4 border-b">
|
||||
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>
|
||||
{version ? `版本 ${version.version}` : "版本快照"}
|
||||
{version ? `版本 ${version.version}` : '版本快照'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>查看历史配置版本的完整快照内容。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载快照...
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="space-y-5 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='space-y-5 pb-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{detail.is_active ? (
|
||||
<Badge className="rounded-full text-[10px]">当前激活</Badge>
|
||||
<Badge className='rounded-full text-[10px]'>当前激活</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="rounded-full text-[10px]">历史版本</Badge>
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
历史版本
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="rounded-full text-[10px] font-mono">
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='rounded-full text-[10px] font-mono'
|
||||
>
|
||||
{detail.checksum}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 text-xs">
|
||||
<div className='grid gap-3 sm:grid-cols-2 text-xs'>
|
||||
<div>
|
||||
<p className="text-muted-foreground">创建人</p>
|
||||
<p className="font-medium">{detail.created_by || "系统"}</p>
|
||||
<p className='text-muted-foreground'>创建人</p>
|
||||
<p className='font-medium'>{detail.created_by || '系统'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">创建时间</p>
|
||||
<p className="font-medium">{formatDateTime(detail.created_at)}</p>
|
||||
<p className='text-muted-foreground'>创建时间</p>
|
||||
<p className='font-medium'>
|
||||
{formatDateTime(detail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold">主配置</p>
|
||||
<CodeBlock code={detail.main_config} language="nginx" className="my-0 text-xs" />
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>主配置</p>
|
||||
<CodeBlock
|
||||
code={detail.main_config}
|
||||
language='nginx'
|
||||
className='my-0 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold">路由配置</p>
|
||||
<CodeBlock code={detail.rendered_config} language="nginx" className="my-0 text-xs" />
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>路由配置</p>
|
||||
<CodeBlock
|
||||
code={detail.rendered_config}
|
||||
language='nginx'
|
||||
className='my-0 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from "react"
|
||||
import {Eye, GitCompare, History, Loader2, Play, RefreshCw, Trash2,} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Eye,
|
||||
GitCompare,
|
||||
History,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
@@ -15,26 +23,33 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
type ConfigDiffResult,
|
||||
type ConfigPreviewResult,
|
||||
ConfigVersionService,
|
||||
type ConfigVersionSummary,
|
||||
} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {CleanupDialog} from "./components/cleanup-dialog"
|
||||
import {DiffDialog} from "./components/diff-dialog"
|
||||
import {PreviewSheet} from "./components/preview-sheet"
|
||||
import {VersionSnapshotSheet} from "./components/version-snapshot-sheet"
|
||||
import { CleanupDialog } from './components/cleanup-dialog';
|
||||
import { DiffDialog } from './components/diff-dialog';
|
||||
import { PreviewSheet } from './components/preview-sheet';
|
||||
import { VersionSnapshotSheet } from './components/version-snapshot-sheet';
|
||||
|
||||
function truncateChecksum(checksum: string) {
|
||||
if (!checksum) return "—"
|
||||
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum
|
||||
if (!checksum) return '—';
|
||||
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
|
||||
}
|
||||
|
||||
function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
@@ -49,285 +64,323 @@ function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
diff.waf_config_changed ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigVersionsPage() {
|
||||
const [versions, setVersions] = useState<ConfigVersionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const [diffOpen, setDiffOpen] = useState(false)
|
||||
const [diff, setDiff] = useState<ConfigDiffResult | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const [diffError, setDiffError] = useState<string | null>(null)
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
|
||||
const [diffLoading, setDiffLoading] = useState(false);
|
||||
const [diffError, setDiffError] = useState<string | null>(null);
|
||||
|
||||
const [snapshotVersion, setSnapshotVersion] = useState<ConfigVersionSummary | null>(null)
|
||||
const [snapshotOpen, setSnapshotOpen] = useState(false)
|
||||
const [snapshotVersion, setSnapshotVersion] =
|
||||
useState<ConfigVersionSummary | null>(null);
|
||||
const [snapshotOpen, setSnapshotOpen] = useState(false);
|
||||
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
|
||||
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false)
|
||||
const [activateTarget, setActivateTarget] = useState<ConfigVersionSummary | null>(null)
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false)
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
|
||||
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false);
|
||||
const [activateTarget, setActivateTarget] =
|
||||
useState<ConfigVersionSummary | null>(null);
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [activating, setActivating] = useState(false)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [activating, setActivating] = useState(false);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
|
||||
const canPublish = useMemo(
|
||||
() => Boolean(preview && preview.route_count > 0 && diff && hasConfigDiff(diff)),
|
||||
() =>
|
||||
Boolean(
|
||||
preview && preview.route_count > 0 && diff && hasConfigDiff(diff),
|
||||
),
|
||||
[preview, diff],
|
||||
)
|
||||
);
|
||||
|
||||
const sortedVersions = useMemo(
|
||||
() =>
|
||||
[...versions].sort(
|
||||
(left, right) =>
|
||||
new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
|
||||
new Date(right.created_at).getTime() -
|
||||
new Date(left.created_at).getTime(),
|
||||
),
|
||||
[versions],
|
||||
)
|
||||
);
|
||||
|
||||
const fetchVersions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await ConfigVersionService.list()
|
||||
setVersions(data)
|
||||
const data = await ConfigVersionService.list();
|
||||
setVersions(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败")
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchVersions()
|
||||
}, [fetchVersions])
|
||||
void fetchVersions();
|
||||
}, [fetchVersions]);
|
||||
|
||||
const loadPreviewData = async () => {
|
||||
setPreviewLoading(true)
|
||||
setPreviewError(null)
|
||||
setDiffLoading(true)
|
||||
setDiffError(null)
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
setDiffLoading(true);
|
||||
setDiffError(null);
|
||||
|
||||
try {
|
||||
const [previewData, diffData] = await Promise.all([
|
||||
ConfigVersionService.preview(),
|
||||
ConfigVersionService.diff(),
|
||||
])
|
||||
setPreview(previewData)
|
||||
setDiff(diffData)
|
||||
]);
|
||||
setPreview(previewData);
|
||||
setDiff(diffData);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "加载预览失败"
|
||||
setPreviewError(message)
|
||||
setDiffError(message)
|
||||
const message = err instanceof Error ? err.message : '加载预览失败';
|
||||
setPreviewError(message);
|
||||
setDiffError(message);
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
setDiffLoading(false)
|
||||
setPreviewLoading(false);
|
||||
setDiffLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPreview = async () => {
|
||||
setPreviewOpen(true)
|
||||
setPreviewOpen(true);
|
||||
if (!preview) {
|
||||
await loadPreviewData()
|
||||
await loadPreviewData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDiff = async () => {
|
||||
setDiffOpen(true)
|
||||
setDiffOpen(true);
|
||||
if (!diff) {
|
||||
setDiffLoading(true)
|
||||
setDiffError(null)
|
||||
setDiffLoading(true);
|
||||
setDiffError(null);
|
||||
try {
|
||||
const diffData = await ConfigVersionService.diff()
|
||||
setDiff(diffData)
|
||||
const diffData = await ConfigVersionService.diff();
|
||||
setDiff(diffData);
|
||||
} catch (err) {
|
||||
setDiffError(err instanceof Error ? err.message : "加载差异失败")
|
||||
setDiffError(err instanceof Error ? err.message : '加载差异失败');
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
setDiffLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (force = false) => {
|
||||
setPublishing(true)
|
||||
setPublishing(true);
|
||||
try {
|
||||
const version = await ConfigVersionService.publish(force)
|
||||
toast.success("发布成功", { description: `版本 ${version.version}` })
|
||||
setPreviewOpen(false)
|
||||
setPublishConfirmOpen(false)
|
||||
setForcePublishConfirmOpen(false)
|
||||
setPreview(null)
|
||||
setDiff(null)
|
||||
await fetchVersions()
|
||||
const version = await ConfigVersionService.publish(force);
|
||||
toast.success('发布成功', { description: `版本 ${version.version}` });
|
||||
setPreviewOpen(false);
|
||||
setPublishConfirmOpen(false);
|
||||
setForcePublishConfirmOpen(false);
|
||||
setPreview(null);
|
||||
setDiff(null);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error("发布失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('发布失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!activateTarget) return
|
||||
if (!activateTarget) return;
|
||||
|
||||
setActivating(true)
|
||||
setActivating(true);
|
||||
try {
|
||||
const version = await ConfigVersionService.activate(activateTarget.id)
|
||||
toast.success("激活成功", { description: `版本 ${version.version}` })
|
||||
setActivateTarget(null)
|
||||
await fetchVersions()
|
||||
const version = await ConfigVersionService.activate(activateTarget.id);
|
||||
toast.success('激活成功', { description: `版本 ${version.version}` });
|
||||
setActivateTarget(null);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error("激活失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('激活失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setActivating(false)
|
||||
setActivating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async (keepCount: number) => {
|
||||
setCleaning(true)
|
||||
setCleaning(true);
|
||||
try {
|
||||
const result = await ConfigVersionService.cleanup({ keep_count: keepCount })
|
||||
toast.success("清理完成", {
|
||||
const result = await ConfigVersionService.cleanup({
|
||||
keep_count: keepCount,
|
||||
});
|
||||
toast.success('清理完成', {
|
||||
description: `已删除 ${result.deleted_count} 个历史快照`,
|
||||
})
|
||||
setCleanupOpen(false)
|
||||
await fetchVersions()
|
||||
});
|
||||
setCleanupOpen(false);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error("清理失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('清理失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
setCleaning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<History className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">配置版本</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>配置版本</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchVersions()} disabled={loading}>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => void fetchVersions()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setCleanupOpen(true)}>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理旧版本
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void handleOpenDiff()}>
|
||||
<GitCompare className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => void handleOpenDiff()}
|
||||
>
|
||||
<GitCompare className='size-3.5 mr-1' />
|
||||
查看差异
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setForcePublishConfirmOpen(true)}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setForcePublishConfirmOpen(true)}
|
||||
>
|
||||
强制发布
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleOpenPreview()}>
|
||||
<Eye className="size-3.5 mr-1" />
|
||||
<Button size='sm' onClick={() => void handleOpenPreview()}>
|
||||
<Eye className='size-3.5 mr-1' />
|
||||
预览并发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <ErrorInline message={error} onRetry={() => void fetchVersions()} /> : null}
|
||||
{error ? (
|
||||
<ErrorInline message={error} onRetry={() => void fetchVersions()} />
|
||||
) : null}
|
||||
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
|
||||
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : sortedVersions.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title="暂无历史版本"
|
||||
description="当前还没有可查看的发布记录,请先触发一次配置发布。"
|
||||
title='暂无历史版本'
|
||||
description='当前还没有可查看的发布记录,请先触发一次配置发布。'
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<TableHead className="text-xs font-semibold">创建人</TableHead>
|
||||
<TableHead className="text-xs font-semibold">Checksum</TableHead>
|
||||
<TableHead className="text-xs font-semibold">创建时间</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
|
||||
<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>
|
||||
<TableHead className='text-xs font-semibold'>创建人</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
Checksum
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
创建时间
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold text-right'>
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedVersions.map((version) => (
|
||||
<TableRow
|
||||
key={version.id}
|
||||
className="border-dashed hover:bg-muted/10 transition-colors"
|
||||
className='border-dashed hover:bg-muted/10 transition-colors'
|
||||
>
|
||||
<TableCell className="font-mono text-xs font-semibold">
|
||||
<TableCell className='font-mono text-xs font-semibold'>
|
||||
{version.version}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{version.is_active ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
|
||||
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" />
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
当前激活
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] rounded-full py-0 px-2">
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] rounded-full py-0 px-2'
|
||||
>
|
||||
历史版本
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{version.created_by || "系统"}
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{version.created_by || '系统'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-xs font-mono text-muted-foreground"
|
||||
className='text-xs font-mono text-muted-foreground'
|
||||
title={version.checksum}
|
||||
>
|
||||
{truncateChecksum(version.checksum)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{formatDateTime(version.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex items-center justify-end gap-1.5'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => {
|
||||
setSnapshotVersion(version)
|
||||
setSnapshotOpen(true)
|
||||
setSnapshotVersion(version);
|
||||
setSnapshotOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3 mr-1" />
|
||||
<Eye className='size-3 mr-1' />
|
||||
快照
|
||||
</Button>
|
||||
{!version.is_active ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setActivateTarget(version)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
<Play className='size-3 mr-1' />
|
||||
激活
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -372,7 +425,10 @@ export default function ConfigVersionsPage() {
|
||||
loading={cleaning}
|
||||
/>
|
||||
|
||||
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
|
||||
<AlertDialog
|
||||
open={publishConfirmOpen}
|
||||
onOpenChange={setPublishConfirmOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
|
||||
@@ -382,14 +438,24 @@ export default function ConfigVersionsPage() {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
|
||||
<Button onClick={() => void handlePublish(false)} disabled={publishing}>
|
||||
{publishing ? <Loader2 className="size-4 animate-spin" /> : "确认发布"}
|
||||
<Button
|
||||
onClick={() => void handlePublish(false)}
|
||||
disabled={publishing}
|
||||
>
|
||||
{publishing ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认发布'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={forcePublishConfirmOpen} onOpenChange={setForcePublishConfirmOpen}>
|
||||
<AlertDialog
|
||||
open={forcePublishConfirmOpen}
|
||||
onOpenChange={setForcePublishConfirmOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认强制发布</AlertDialogTitle>
|
||||
@@ -400,11 +466,15 @@ export default function ConfigVersionsPage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant='destructive'
|
||||
onClick={() => void handlePublish(true)}
|
||||
disabled={publishing}
|
||||
>
|
||||
{publishing ? <Loader2 className="size-4 animate-spin" /> : "强制发布"}
|
||||
{publishing ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'强制发布'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -413,7 +483,7 @@ export default function ConfigVersionsPage() {
|
||||
<AlertDialog
|
||||
open={activateTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActivateTarget(null)
|
||||
if (!open) setActivateTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
@@ -422,17 +492,21 @@ export default function ConfigVersionsPage() {
|
||||
<AlertDialogDescription>
|
||||
{activateTarget
|
||||
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?`
|
||||
: ""}
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel>
|
||||
<Button onClick={() => void handleActivate()} disabled={activating}>
|
||||
{activating ? <Loader2 className="size-4 animate-spin" /> : "确认激活"}
|
||||
{activating ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认激活'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Cloud, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Cloud, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -15,17 +15,23 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {DnsAccountItem} from '@/lib/services/openflare';
|
||||
import {DnsAccountService} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type { DnsAccountItem } from '@/lib/services/openflare';
|
||||
import { DnsAccountService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {DnsAccountCreateDialog} from '../websites/components/dns-account-create-dialog';
|
||||
import {getErrorMessage} from '../websites/components/website-utils';
|
||||
import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog';
|
||||
import { getErrorMessage } from '../websites/components/website-utils';
|
||||
|
||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
||||
|
||||
@@ -44,7 +50,7 @@ export default function DnsAccountsPage() {
|
||||
onSuccess: async () => {
|
||||
toast.success('DNS 账号已删除');
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
@@ -55,68 +61,77 @@ export default function DnsAccountsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">DNS 账号</h1>
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Cloud className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>DNS 账号</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">DNS 账号列表</CardTitle>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
DNS 账号列表
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Cloud} description="加载 DNS 账号中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description='加载 DNS 账号中...'
|
||||
/>
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(dnsAccountsQuery.error)}
|
||||
onRetry={() => void dnsAccountsQuery.refetch()}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Cloud}
|
||||
description="暂无 DNS 账号,点击右上角「添加账号」开始录入。"
|
||||
description='暂无 DNS 账号,点击右上角「添加账号」开始录入。'
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border bg-card px-4 py-3"
|
||||
className='flex items-start justify-between gap-3 rounded-lg border bg-card px-4 py-3'
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-semibold'>
|
||||
{account.name}{' '}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
({account.type})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
创建于:{formatDateTime(account.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs text-destructive'
|
||||
onClick={() => setDeleteTarget(account)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
<Trash2 className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -145,8 +160,10 @@ export default function DnsAccountsPage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
@@ -155,4 +172,4 @@ export default function DnsAccountsPage() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
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"
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<UserFileManager />
|
||||
</motion.div>
|
||||
<UserFileManager />
|
||||
</motion.div>
|
||||
</RequireAuth>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {redirect} from "next/navigation"
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/")
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {Loader2, Network, Plus, RefreshCw} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Network, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -15,19 +15,33 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {WAFIPGroup, WAFIPGroupAutoTestResult, WAFIPGroupPayload,} from '@/lib/services/openflare';
|
||||
import {WafService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type {
|
||||
WAFIPGroup,
|
||||
WAFIPGroupAutoTestResult,
|
||||
WAFIPGroupPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
import { WafService } from '@/lib/services/openflare';
|
||||
|
||||
import {buildIPGroupPayloadFromGroup, getErrorMessage, parseAutomaticConfig} from '../waf/components/helpers';
|
||||
import {IPGroupDialog} from '../waf/components/ip-group-dialog';
|
||||
import {IPGroupTestDialog} from '../waf/components/ip-group-test-dialog';
|
||||
import {IPGroupViewDialog} from '../waf/components/ip-group-view-dialog';
|
||||
import {IPGroupsTable} from '../waf/components/ip-groups-table';
|
||||
import {
|
||||
buildIPGroupPayloadFromGroup,
|
||||
getErrorMessage,
|
||||
parseAutomaticConfig,
|
||||
} from '../waf/components/helpers';
|
||||
import { IPGroupDialog } from '../waf/components/ip-group-dialog';
|
||||
import { IPGroupTestDialog } from '../waf/components/ip-group-test-dialog';
|
||||
import { IPGroupViewDialog } from '../waf/components/ip-group-view-dialog';
|
||||
import { IPGroupsTable } from '../waf/components/ip-groups-table';
|
||||
|
||||
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
|
||||
|
||||
@@ -37,7 +51,9 @@ export default function WafIPGroupsPage() {
|
||||
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WAFIPGroup | null>(null);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(null);
|
||||
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(
|
||||
null,
|
||||
);
|
||||
const [syncingId, setSyncingId] = useState<number | null>(null);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [viewingGroup, setViewingGroup] = useState<WAFIPGroup | null>(null);
|
||||
@@ -51,8 +67,12 @@ export default function WafIPGroupsPage() {
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'waf', 'rule-groups'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions', 'diff'],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -118,7 +138,10 @@ export default function WafIPGroupsPage() {
|
||||
const removeIpMutation = useMutation({
|
||||
mutationFn: async ({ group, ip }: { group: WAFIPGroup; ip: string }) => {
|
||||
const nextIpList = group.ip_list.filter((item) => item !== ip);
|
||||
return WafService.updateIPGroup(group.id, buildIPGroupPayloadFromGroup(group, nextIpList));
|
||||
return WafService.updateIPGroup(
|
||||
group.id,
|
||||
buildIPGroupPayloadFromGroup(group, nextIpList),
|
||||
);
|
||||
},
|
||||
onMutate: ({ ip }) => {
|
||||
setRemovingIp(ip);
|
||||
@@ -192,40 +215,47 @@ export default function WafIPGroupsPage() {
|
||||
const viewGroup = viewGroupQuery.data ?? viewingGroup;
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">IP 组</h1>
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Network className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>IP 组</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新建 IP 组
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">IP 组列表</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
IP 组列表
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={handleRefresh}
|
||||
disabled={groupsQuery.isFetching}
|
||||
>
|
||||
{groupsQuery.isFetching ? (
|
||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
||||
<Loader2 className='size-3.5 mr-1 animate-spin' />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1" />
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
刷新
|
||||
</Button>
|
||||
@@ -233,17 +263,23 @@ export default function WafIPGroupsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Network} description="加载 IP 组中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Network}
|
||||
description='加载 IP 组中...'
|
||||
/>
|
||||
) : groupsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(groupsQuery.error)}
|
||||
onRetry={handleRefresh}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Network} description="暂无 IP 组,请先创建一个。" />
|
||||
<EmptyStateWithBorder
|
||||
icon={Network}
|
||||
description='暂无 IP 组,请先创建一个。'
|
||||
/>
|
||||
) : (
|
||||
<IPGroupsTable
|
||||
groups={groups}
|
||||
@@ -304,11 +340,15 @@ export default function WafIPGroupsPage() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
"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"
|
||||
'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
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const [isFullWidth, setIsFullWidth] = useState(false)
|
||||
const pathname = usePathname();
|
||||
const [isFullWidth, setIsFullWidth] = useState(false);
|
||||
|
||||
useAuthRedirect()
|
||||
useAuthRedirect();
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen"
|
||||
className='h-screen'
|
||||
style={
|
||||
{
|
||||
"--header-height": "60px",
|
||||
'--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"}`}>
|
||||
<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",
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
@@ -49,5 +53,5 @@ export default function MainLayout({
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel,} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
@@ -31,7 +31,10 @@ export function AgentUpdateDialog({
|
||||
node: NodeItem;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (release: NodeAgentReleaseInfo | null, channel: ReleaseChannel) => Promise<void>;
|
||||
onConfirm: (
|
||||
release: NodeAgentReleaseInfo | null,
|
||||
channel: ReleaseChannel,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [channel, setChannel] = useState<ReleaseChannel>('stable');
|
||||
|
||||
@@ -45,7 +48,7 @@ export function AgentUpdateDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agent 升级</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -53,21 +56,23 @@ export function AgentUpdateDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">当前版本</p>
|
||||
<p className="mt-1 text-sm font-medium">{node.version || 'unknown'}</p>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>当前版本</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{node.version || 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">检查通道</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>检查通道</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{channel === 'preview' ? '预览版' : '正式版'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">更新状态</p>
|
||||
<div className="mt-1">
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>更新状态</p>
|
||||
<div className='mt-1'>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
node.update_requested
|
||||
@@ -83,76 +88,82 @@ export function AgentUpdateDialog({
|
||||
</div>
|
||||
|
||||
{releaseQuery.isFetching ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 mr-2 animate-spin' />
|
||||
检查发布版本中...
|
||||
</div>
|
||||
) : releaseQuery.isError ? (
|
||||
<p className="text-sm text-destructive">{getErrorMessage(releaseQuery.error)}</p>
|
||||
<p className='text-sm text-destructive'>
|
||||
{getErrorMessage(releaseQuery.error)}
|
||||
</p>
|
||||
) : release ? (
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className='space-y-3 rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<NodeStatusBadge
|
||||
label={release.has_update ? '发现可升级版本' : '当前已是最新版本'}
|
||||
label={
|
||||
release.has_update ? '发现可升级版本' : '当前已是最新版本'
|
||||
}
|
||||
tone={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<NodeStatusBadge label="Preview 发布" tone="warning" />
|
||||
<NodeStatusBadge label='Preview 发布' tone='warning' />
|
||||
) : (
|
||||
<NodeStatusBadge label="正式发布" tone="info" />
|
||||
<NodeStatusBadge label='正式发布' tone='info' />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 text-sm">
|
||||
<div className='grid gap-3 sm:grid-cols-2 text-sm'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">目标版本</p>
|
||||
<p className="mt-1 font-medium">{release.tag_name || '未找到'}</p>
|
||||
<p className='text-xs text-muted-foreground'>目标版本</p>
|
||||
<p className='mt-1 font-medium'>
|
||||
{release.tag_name || '未找到'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">发布时间</p>
|
||||
<p className="mt-1">
|
||||
<p className='text-xs text-muted-foreground'>发布时间</p>
|
||||
<p className='mt-1'>
|
||||
{release.published_at
|
||||
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
<p className='text-sm text-muted-foreground whitespace-pre-wrap'>
|
||||
{release.body || '暂无更新说明'}
|
||||
</p>
|
||||
{release.html_url ? (
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-sm text-primary hover:underline"
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='inline-flex items-center text-sm text-primary hover:underline'
|
||||
>
|
||||
查看发布详情
|
||||
<ExternalLink className="size-3 ml-1" />
|
||||
<ExternalLink className='size-3 ml-1' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className='gap-2 sm:gap-0'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={submitting || releaseQuery.isFetching}
|
||||
onClick={() => setChannel('stable')}
|
||||
>
|
||||
检查正式版
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={submitting || releaseQuery.isFetching}
|
||||
onClick={() => setChannel('preview')}
|
||||
>
|
||||
检查预览版
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
type='button'
|
||||
disabled={
|
||||
submitting ||
|
||||
releaseQuery.isFetching ||
|
||||
@@ -161,7 +172,11 @@ export function AgentUpdateDialog({
|
||||
}
|
||||
onClick={() => void onConfirm(release, channel)}
|
||||
>
|
||||
{submitting ? '下发中...' : channel === 'preview' ? '升级到预览版' : '升级到正式版'}
|
||||
{submitting
|
||||
? '下发中...'
|
||||
: channel === 'preview'
|
||||
? '升级到预览版'
|
||||
: '升级到正式版'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {DiskIOTrendPoint} from '@/lib/services/openflare';
|
||||
|
||||
import {formatBytes, formatTrendHour} from '../../components/dashboard/dashboard-utils';
|
||||
import {formatBytes, formatTrendHour,} from '../../components/dashboard/dashboard-utils';
|
||||
|
||||
export function DiskIOTrendChart({
|
||||
points,
|
||||
@@ -16,16 +16,18 @@ export function DiskIOTrendChart({
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
summaryScope="total"
|
||||
summaryHint="近 24 小时"
|
||||
labels={points.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
|
||||
@@ -18,28 +18,30 @@ export function DistributionList({
|
||||
|
||||
if (sortedItems.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
|
||||
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{sortedItems.map((item) => {
|
||||
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||
return (
|
||||
<div key={item.label} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate font-medium">{item.label || '未知'}</span>
|
||||
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
|
||||
<div key={item.label} className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between gap-3 text-xs'>
|
||||
<span className='truncate font-medium'>
|
||||
{item.label || '未知'}
|
||||
</span>
|
||||
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
|
||||
{formatCompactNumber(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={ratio} className="h-1.5" />
|
||||
<Progress value={ratio} className='h-1.5' />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,7 @@ import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useState} from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
FileText,
|
||||
Fingerprint,
|
||||
Package,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import {Activity, Cpu, FileText, Fingerprint, Package, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -29,16 +19,12 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel,} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
|
||||
import {AgentUpdateDialog} from './agent-update-dialog';
|
||||
import {NodeDetailShell} from './node-detail-shell';
|
||||
import {
|
||||
NodeErrorBanner,
|
||||
NodeInfoRow,
|
||||
NodeSectionCard,
|
||||
} from './node-detail-primitives';
|
||||
import {NodeErrorBanner, NodeInfoRow, NodeSectionCard,} from './node-detail-primitives';
|
||||
import {NodeEditorDialog} from './node-editor-dialog';
|
||||
import {NodeObservability} from './node-observability';
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
@@ -102,7 +88,10 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
}) =>
|
||||
NodeService.requestAgentUpdate(node.id, {
|
||||
channel: release?.channel ?? channel,
|
||||
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
|
||||
tag_name:
|
||||
release?.channel === 'preview'
|
||||
? release.tag_name || undefined
|
||||
: undefined,
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
@@ -127,114 +116,146 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
const handleRefresh = () => {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'node-observability', node.id],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
disabled={forceSyncMutation.isPending}
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className="size-3.5 mr-1.5" />
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
disabled={restartMutation.isPending}
|
||||
onClick={() => restartMutation.mutate()}
|
||||
>
|
||||
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
|
||||
<Upload className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 Agent'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 text-destructive hover:text-destructive'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<NodeSectionCard title="运行状态" description="节点在线情况与 OpenResty 健康">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="运行状态">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='运行状态'
|
||||
description='节点在线情况与 OpenResty 健康'
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="OpenResty 健康">
|
||||
<NodeInfoRow label='OpenResty 健康'>
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近心跳">
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
{isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="IP 地址">
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="地图点位">{node.geo_name || '未配置'}</NodeInfoRow>
|
||||
<NodeInfoRow label='地图点位'>
|
||||
{node.geo_name || '未配置'}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title="配置同步"
|
||||
description="版本追平与应用结果摘要"
|
||||
title='配置同步'
|
||||
description='版本追平与应用结果摘要'
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="h-8" asChild>
|
||||
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
|
||||
<FileText className="size-3.5 mr-1.5" />
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="当前配置版本">{node.current_version || '未应用'}</NodeInfoRow>
|
||||
<NodeInfoRow label="最近应用">
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='当前配置版本'>
|
||||
{node.current_version || '未应用'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近应用时间">
|
||||
<NodeInfoRow label='最近应用时间'>
|
||||
{isMeaningfulTime(node.latest_apply_at)
|
||||
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: '暂无'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="Agent 版本">{node.version || 'unknown'}</NodeInfoRow>
|
||||
<NodeInfoRow label="Nginx 版本">{node.ext_version || 'unknown'}</NodeInfoRow>
|
||||
<NodeInfoRow label="自动更新">{node.auto_update_enabled ? '已启用' : '手动'}</NodeInfoRow>
|
||||
<NodeInfoRow label='Agent 版本'>
|
||||
{node.version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Nginx 版本'>
|
||||
{node.ext_version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='自动更新'>
|
||||
{node.auto_update_enabled ? '已启用' : '手动'}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
@@ -242,33 +263,45 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className="space-y-6">
|
||||
<NodeSectionCard title="节点标识" description="用于 Agent 接入与鉴别的核心字段">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="节点 ID">
|
||||
<span className="font-mono text-xs break-all">{node.node_id}</span>
|
||||
<div className='space-y-6'>
|
||||
<NodeSectionCard
|
||||
title='节点标识'
|
||||
description='用于 Agent 接入与鉴别的核心字段'
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="Agent Token">
|
||||
<span className="font-mono text-xs break-all">{node.access_token || '暂无'}</span>
|
||||
<NodeInfoRow label='Agent Token'>
|
||||
<span className='font-mono text-xs break-all'>
|
||||
{node.access_token || '暂无'}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
|
||||
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard title="运行消息" description="OpenResty 与 Agent 上报的状态说明">
|
||||
<div className="space-y-4 text-sm">
|
||||
<NodeSectionCard
|
||||
title='运行消息'
|
||||
description='OpenResty 与 Agent 上报的状态说明'
|
||||
>
|
||||
<div className='space-y-4 text-sm'>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
|
||||
OpenResty 状态消息
|
||||
</p>
|
||||
<p className="rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap">
|
||||
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'>
|
||||
{node.openresty_message || '当前未上报额外错误。'}
|
||||
</p>
|
||||
</div>
|
||||
{node.restart_openresty_requested ? (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<Activity className="size-4" />
|
||||
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
|
||||
<Activity className='size-4' />
|
||||
已等待节点在下一次心跳后执行 OpenResty 重启。
|
||||
</div>
|
||||
) : null}
|
||||
@@ -281,10 +314,13 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel="Edge"
|
||||
typeTone="info"
|
||||
typeLabel='Edge'
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getOpenrestyStatusLabel(node.openresty_status),
|
||||
tone: getOpenrestyStatusTone(node.openresty_status),
|
||||
@@ -294,7 +330,11 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
|
||||
{ label: '当前配置', value: node.current_version || '未应用', icon: Package },
|
||||
{
|
||||
label: '当前配置',
|
||||
value: node.current_version || '未应用',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
value: isMeaningfulTime(node.last_seen_at)
|
||||
@@ -308,12 +348,12 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
node={node}
|
||||
variant="edge"
|
||||
connectionHint="OpenResty 当前连接"
|
||||
variant='edge'
|
||||
connectionHint='OpenResty 当前连接'
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
defaultTab="overview"
|
||||
defaultTab='overview'
|
||||
/>
|
||||
|
||||
<NodeEditorDialog
|
||||
@@ -341,13 +381,16 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
|
||||
确认删除节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
@@ -358,4 +401,4 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Copy} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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 {type NodeItem, StatusService} from '@/lib/services/openflare';
|
||||
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 { type NodeItem, StatusService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
buildRelayDockerInstallCommand,
|
||||
@@ -86,8 +92,16 @@ export function InstallCommand({
|
||||
return '';
|
||||
}
|
||||
return variant === 'relay'
|
||||
? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token, serverVersion)
|
||||
: buildTunnelDockerInstallCommand(normalizedServerUrl, node.access_token, serverVersion);
|
||||
? buildRelayDockerInstallCommand(
|
||||
normalizedServerUrl,
|
||||
node.access_token,
|
||||
serverVersion,
|
||||
)
|
||||
: buildTunnelDockerInstallCommand(
|
||||
normalizedServerUrl,
|
||||
node.access_token,
|
||||
serverVersion,
|
||||
);
|
||||
}, [normalizedServerUrl, node.access_token, variant, serverVersion]);
|
||||
|
||||
const handleCopy = async (value: string, message: string) => {
|
||||
@@ -100,77 +114,91 @@ export function InstallCommand({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 gap-4">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex-row items-start justify-between space-y-0 gap-4'>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">{meta.title}</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{meta.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{meta.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{scriptCommand ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleCopy(scriptCommand, '部署脚本命令已复制')}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(scriptCommand, '部署脚本命令已复制')
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
复制脚本
|
||||
</Button>
|
||||
) : null}
|
||||
{dockerCommand ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleCopy(dockerCommand, 'Docker 部署命令已复制')}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(dockerCommand, 'Docker 部署命令已复制')
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
复制 Docker
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">Node ID</p>
|
||||
<p className="mt-1 text-sm break-all font-medium">{node.node_id}</p>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground'>Node ID</p>
|
||||
<p className='mt-1 text-sm break-all font-medium'>{node.node_id}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">{meta.tokenLabel}</p>
|
||||
<p className="mt-1 text-sm break-all font-medium">{node.access_token || '暂无'}</p>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p>
|
||||
<p className='mt-1 text-sm break-all font-medium'>
|
||||
{node.access_token || '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`server-url-${variant}`}>Server URL(控制端地址)</Label>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor={`server-url-${variant}`}>
|
||||
Server URL(控制端地址)
|
||||
</Label>
|
||||
<Input
|
||||
id={`server-url-${variant}`}
|
||||
value={serverUrl}
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
placeholder="https://openflare.example.com"
|
||||
placeholder='https://openflare.example.com'
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!node.access_token ? (
|
||||
<p className="text-sm text-muted-foreground">节点尚未生成接入 Token,请稍后刷新。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点尚未生成接入 Token,请稍后刷新。
|
||||
</p>
|
||||
) : !normalizedServerUrl ? (
|
||||
<p className="text-sm text-muted-foreground">请填写有效的 Server URL 以生成部署命令。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
请填写有效的 Server URL 以生成部署命令。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{meta.scriptLabel}</p>
|
||||
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
|
||||
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
|
||||
{scriptCommand}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{meta.dockerLabel}</p>
|
||||
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>{meta.dockerLabel}</p>
|
||||
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
|
||||
{dockerCommand}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -179,4 +207,4 @@ export function InstallCommand({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import type {NetworkTrendPoint} from '@/lib/services/openflare';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type { NetworkTrendPoint } from '@/lib/services/openflare';
|
||||
|
||||
import {formatBytes, formatTrendHour} from '../../components/dashboard/dashboard-utils';
|
||||
import {
|
||||
formatBytes,
|
||||
formatTrendHour,
|
||||
} from '../../components/dashboard/dashboard-utils';
|
||||
|
||||
export function NetworkTrendChart({
|
||||
points,
|
||||
@@ -16,16 +25,18 @@ export function NetworkTrendChart({
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
summaryScope="total"
|
||||
summaryHint="近 24 小时"
|
||||
labels={points.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function NodeInfoRow({
|
||||
label,
|
||||
@@ -16,9 +22,14 @@ export function NodeInfoRow({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4 py-2.5 text-sm', className)}>
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<div className="min-w-0 text-right font-medium">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-4 py-2.5 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className='shrink-0 text-muted-foreground'>{label}</span>
|
||||
<div className='min-w-0 text-right font-medium'>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,12 +44,14 @@ export function NodeKpiCard({
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card/60 px-4 py-3.5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{Icon ? <Icon className="size-3.5 opacity-70" /> : null}
|
||||
<div className='rounded-xl border bg-card/60 px-4 py-3.5 backdrop-blur-sm'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
{Icon ? <Icon className='size-3.5 opacity-70' /> : null}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold tracking-tight break-all">{value}</p>
|
||||
<p className='mt-2 text-sm font-semibold tracking-tight break-all'>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +71,17 @@ export function NodeSectionCard({
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('border shadow-none', className)}>
|
||||
<CardHeader className={cn('pb-3', action ? 'flex-row items-start justify-between space-y-0' : '')}>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
<CardHeader
|
||||
className={cn(
|
||||
'pb-3',
|
||||
action ? 'flex-row items-start justify-between space-y-0' : '',
|
||||
)}
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle className='text-base font-semibold'>{title}</CardTitle>
|
||||
{description ? (
|
||||
<CardDescription>{description}</CardDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{action}
|
||||
</CardHeader>
|
||||
@@ -72,8 +92,8 @@ export function NodeSectionCard({
|
||||
|
||||
export function NodeErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<div className='rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive'>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {type ReactNode, useCallback, useMemo} from 'react';
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import {ArrowLeft} from 'lucide-react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {cn} from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {NodeKpiCard} from './node-detail-primitives';
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
import type {StatusTone} from './node-utils';
|
||||
import { NodeKpiCard } from './node-detail-primitives';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import type { StatusTone } from './node-utils';
|
||||
|
||||
export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
|
||||
|
||||
@@ -89,23 +89,30 @@ export function NodeDetailShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<section className="overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30">
|
||||
<div className="flex flex-col gap-5 p-5 md:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button variant="ghost" size="sm" className="mt-0.5 h-8 w-8 shrink-0 p-0" asChild>
|
||||
<Link href="/nodes" aria-label="返回节点列表">
|
||||
<ArrowLeft className="size-4" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<section className='overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30'>
|
||||
<div className='flex flex-col gap-5 p-5 md:p-6'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='mt-0.5 h-8 w-8 shrink-0 p-0'
|
||||
asChild
|
||||
>
|
||||
<Link href='/nodes' aria-label='返回节点列表'>
|
||||
<ArrowLeft className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{title}
|
||||
</h1>
|
||||
<NodeStatusBadge label={typeLabel} tone={typeTone} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{statusBadges.map((badge) => (
|
||||
<NodeStatusBadge
|
||||
key={badge.label}
|
||||
@@ -117,11 +124,11 @@ export function NodeDetailShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>{actions}</div>
|
||||
</div>
|
||||
|
||||
{kpis.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{kpis.map((kpi) => (
|
||||
<NodeKpiCard
|
||||
key={kpi.label}
|
||||
@@ -142,10 +149,13 @@ export function NodeDetailShell({
|
||||
setActiveTab(value);
|
||||
}
|
||||
}}
|
||||
className="w-full gap-0"
|
||||
className='w-full gap-0'
|
||||
>
|
||||
<div className="space-y-3 pb-1">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start gap-6 bg-transparent p-0">
|
||||
<div className='space-y-3 pb-1'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='h-auto w-full justify-start gap-6 bg-transparent p-0'
|
||||
>
|
||||
{TAB_CONFIGS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
@@ -161,13 +171,13 @@ export function NodeDetailShell({
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="mt-6 outline-none">
|
||||
<TabsContent value='overview' className='mt-6 outline-none'>
|
||||
{activeTab === 'overview' ? overview : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="dashboard" className="mt-6 outline-none">
|
||||
<TabsContent value='dashboard' className='mt-6 outline-none'>
|
||||
{activeTab === 'dashboard' ? dashboard : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="manage" className="mt-6 outline-none">
|
||||
<TabsContent value='manage' className='mt-6 outline-none'>
|
||||
{activeTab === 'manage' ? manage : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
import { useEffect } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,11 +14,21 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type {
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
NodeType,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
const nodeSchema = z
|
||||
.object({
|
||||
@@ -111,12 +121,14 @@ function toPayload(form: NodeFormValues): NodeMutationPayload {
|
||||
auto_update_enabled: form.auto_update_enabled,
|
||||
geo_manual_override: form.geo_manual_override,
|
||||
geo_name: form.geo_manual_override ? form.geo_name.trim() : '',
|
||||
geo_latitude: form.geo_manual_override && form.geo_latitude
|
||||
? Number(form.geo_latitude)
|
||||
: null,
|
||||
geo_longitude: form.geo_manual_override && form.geo_longitude
|
||||
? Number(form.geo_longitude)
|
||||
: null,
|
||||
geo_latitude:
|
||||
form.geo_manual_override && form.geo_latitude
|
||||
? Number(form.geo_latitude)
|
||||
: null,
|
||||
geo_longitude:
|
||||
form.geo_manual_override && form.geo_longitude
|
||||
? Number(form.geo_longitude)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (form.node_type === 'tunnel_relay') {
|
||||
@@ -165,7 +177,7 @@ export function NodeEditorDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -173,8 +185,11 @@ export function NodeEditorDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<form
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label>节点类型</Label>
|
||||
<Select
|
||||
value={nodeType}
|
||||
@@ -187,45 +202,47 @@ export function NodeEditorDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edge_node">Edge 节点</SelectItem>
|
||||
<SelectItem value="tunnel_relay">Relay 节点</SelectItem>
|
||||
<SelectItem value="tunnel_client">Tunnel 节点</SelectItem>
|
||||
<SelectItem value='edge_node'>Edge 节点</SelectItem>
|
||||
<SelectItem value='tunnel_relay'>Relay 节点</SelectItem>
|
||||
<SelectItem value='tunnel_client'>Tunnel 节点</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-name">节点名称</Label>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-name'>节点名称</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
placeholder="例如 edge-hk-01"
|
||||
id='node-name'
|
||||
placeholder='例如 edge-hk-01'
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-ip">节点 IP</Label>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-ip'>节点 IP</Label>
|
||||
<Input
|
||||
id="node-ip"
|
||||
placeholder="可选,接入后自动上报"
|
||||
id='node-ip'
|
||||
placeholder='可选,接入后自动上报'
|
||||
{...form.register('ip')}
|
||||
/>
|
||||
{form.formState.errors.ip ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.ip.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className="text-sm font-medium">锁定 IP</p>
|
||||
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
|
||||
<p className='text-sm font-medium'>锁定 IP</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后管理端不再自动覆盖 IP
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ipManualOverride}
|
||||
@@ -235,10 +252,12 @@ export function NodeEditorDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className="text-sm font-medium">自动更新 Agent</p>
|
||||
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
|
||||
<p className='text-sm font-medium'>自动更新 Agent</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后节点将自动拉取正式版更新
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('auto_update_enabled')}
|
||||
@@ -249,27 +268,27 @@ export function NodeEditorDialog({
|
||||
</div>
|
||||
|
||||
{nodeType === 'tunnel_relay' ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relay-bind-port">绑定端口</Label>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-bind-port'>绑定端口</Label>
|
||||
<Input
|
||||
id="relay-bind-port"
|
||||
id='relay-bind-port'
|
||||
{...form.register('relay_bind_port')}
|
||||
/>
|
||||
{form.formState.errors.relay_bind_port ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.relay_bind_port.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
|
||||
<Input
|
||||
id="relay-vhost-port"
|
||||
id='relay-vhost-port'
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
/>
|
||||
{form.formState.errors.relay_vhost_http_port ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.relay_vhost_http_port.message}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -277,10 +296,10 @@ export function NodeEditorDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className="text-sm font-medium">手动地图点位</p>
|
||||
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
|
||||
<p className='text-sm font-medium'>手动地图点位</p>
|
||||
<p className='text-xs text-muted-foreground'>用于总览地图展示</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={geoManualOverride}
|
||||
@@ -291,27 +310,32 @@ export function NodeEditorDialog({
|
||||
</div>
|
||||
|
||||
{geoManualOverride ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<Label htmlFor="geo-name">位置名称</Label>
|
||||
<Input id="geo-name" {...form.register('geo_name')} />
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='space-y-2 sm:col-span-3'>
|
||||
<Label htmlFor='geo-name'>位置名称</Label>
|
||||
<Input id='geo-name' {...form.register('geo_name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geo-lat">纬度</Label>
|
||||
<Input id="geo-lat" {...form.register('geo_latitude')} />
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='geo-lat'>纬度</Label>
|
||||
<Input id='geo-lat' {...form.register('geo_latitude')} />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="geo-lng">经度</Label>
|
||||
<Input id="geo-lng" {...form.register('geo_longitude')} />
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Label htmlFor='geo-lng'>经度</Label>
|
||||
<Input id='geo-lng' {...form.register('geo_longitude')} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -319,4 +343,4 @@ export function NodeEditorDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {Loader2, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -15,18 +15,28 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeHealthEvent, NodeItem, NodeSystemProfile} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import type {
|
||||
NodeHealthEvent,
|
||||
NodeItem,
|
||||
NodeSystemProfile,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
|
||||
import {CapacityTrendChart} from '../../components/dashboard/capacity-trend-chart';
|
||||
import {TrafficTrendChart} from '../../components/dashboard/traffic-trend-chart';
|
||||
import {DiskIOTrendChart} from './disk-io-trend-chart';
|
||||
import {DistributionList} from './distribution-list';
|
||||
import {NetworkTrendChart} from './network-trend-chart';
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
import { CapacityTrendChart } from '../../components/dashboard/capacity-trend-chart';
|
||||
import { TrafficTrendChart } from '../../components/dashboard/traffic-trend-chart';
|
||||
import { DiskIOTrendChart } from './disk-io-trend-chart';
|
||||
import { DistributionList } from './distribution-list';
|
||||
import { NetworkTrendChart } from './network-trend-chart';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import {
|
||||
aggregateTrafficBreakdown,
|
||||
formatBytes,
|
||||
@@ -61,18 +71,22 @@ function MetricBar({
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className='space-y-2 rounded-lg border px-3 py-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">{label}</p>
|
||||
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
{label}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className='mt-1 text-xs text-muted-foreground'>{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
<p className='text-sm font-medium'>{value}</p>
|
||||
</div>
|
||||
{progress !== null && progress !== undefined ? (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className='h-2 overflow-hidden rounded-full bg-muted'>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width]"
|
||||
className='h-full rounded-full bg-primary transition-[width]'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -91,11 +105,11 @@ function SummaryStat({
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-base font-semibold">{value}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{hint}</p>
|
||||
<CardTitle className='text-base font-semibold'>{value}</CardTitle>
|
||||
<p className='text-sm text-muted-foreground'>{hint}</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
@@ -109,48 +123,71 @@ function SystemProfileCard({
|
||||
nodeName: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-4 rounded-lg border px-4 py-4'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">主机名</p>
|
||||
<p className="mt-2 text-sm">{profile.hostname || nodeName}</p>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
主机名
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">操作系统</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
操作系统
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{profile.os_name || 'unknown'}
|
||||
{profile.os_version ? ` ${profile.os_version}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">内核 / 架构</p>
|
||||
<p className="mt-2 text-sm">
|
||||
{profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
内核 / 架构
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{profile.kernel_version || 'unknown'} ·{' '}
|
||||
{profile.architecture || 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">在线时长</p>
|
||||
<p className="mt-2 text-sm">{formatUptime(profile.uptime_seconds)}</p>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
在线时长
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatUptime(profile.uptime_seconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||
<div className='space-y-4 rounded-lg border px-4 py-4'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">CPU</p>
|
||||
<p className="mt-2 text-sm">{profile.cpu_model || 'unknown'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{profile.cpu_cores || 0} 核</p>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
CPU
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{profile.cpu_cores || 0} 核
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">总内存</p>
|
||||
<p className="mt-2 text-sm">{formatBytes(profile.total_memory_bytes)}</p>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
总内存
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{formatBytes(profile.total_memory_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">总存储</p>
|
||||
<p className="mt-2 text-sm">{formatBytes(profile.total_disk_bytes)}</p>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
总存储
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{formatBytes(profile.total_disk_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">上报时间</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
上报时间
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{isMeaningfulTime(profile.reported_at)
|
||||
? formatDateTime(profile.reported_at)
|
||||
: '—'}
|
||||
@@ -177,27 +214,31 @@ function HealthEventTimeline({
|
||||
cleanupPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex-row items-center justify-between space-y-0'>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">健康事件时间线</CardTitle>
|
||||
<CardDescription>保留活动与已恢复事件,帮助判断运行状态。</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
健康事件时间线
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
保留活动与已恢复事件,帮助判断运行状态。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs text-destructive hover:text-destructive'
|
||||
disabled={cleanupPending || allEventsCount === 0}
|
||||
onClick={onCleanup}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className='space-y-4'>
|
||||
{allEventsCount > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{(
|
||||
[
|
||||
['all', '全部事件'],
|
||||
@@ -207,10 +248,12 @@ function HealthEventTimeline({
|
||||
).map(([filter, label]) => (
|
||||
<Button
|
||||
key={filter}
|
||||
type="button"
|
||||
variant={healthEventFilter === filter ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs rounded-full"
|
||||
type='button'
|
||||
variant={
|
||||
healthEventFilter === filter ? 'secondary' : 'outline'
|
||||
}
|
||||
size='sm'
|
||||
className='h-7 text-xs rounded-full'
|
||||
onClick={() => onFilterChange(filter)}
|
||||
>
|
||||
{label}
|
||||
@@ -219,15 +262,17 @@ function HealthEventTimeline({
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">当前筛选下没有健康事件。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
当前筛选下没有健康事件。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{events.slice(0, 8).map((event) => (
|
||||
<div
|
||||
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
|
||||
className="rounded-lg border px-4 py-3"
|
||||
className='rounded-lg border px-4 py-3'
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<NodeStatusBadge
|
||||
label={getHealthEventLabel(event)}
|
||||
tone={getHealthEventTone(event)}
|
||||
@@ -237,10 +282,10 @@ function HealthEventTimeline({
|
||||
tone={event.status === 'active' ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{event.message || '暂无详细消息'}
|
||||
</p>
|
||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3">
|
||||
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
|
||||
<p>
|
||||
首次触发:
|
||||
{isMeaningfulTime(event.first_triggered_at)
|
||||
@@ -264,7 +309,9 @@ function HealthEventTimeline({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">节点当前还没有上报健康事件记录。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点当前还没有上报健康事件记录。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -283,12 +330,14 @@ export function NodeObservability({
|
||||
connectionHint?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [healthEventFilter, setHealthEventFilter] = useState<HealthEventFilter>('all');
|
||||
const [healthEventFilter, setHealthEventFilter] =
|
||||
useState<HealthEventFilter>('all');
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
|
||||
const observabilityQuery = useQuery({
|
||||
queryKey: ['openflare', 'node-observability', nodeId],
|
||||
queryFn: () => NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
|
||||
queryFn: () =>
|
||||
NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
@@ -305,7 +354,9 @@ export function NodeObservability({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'node-observability', nodeId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'dashboard', 'overview'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'dashboard', 'overview'],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
@@ -315,11 +366,17 @@ export function NodeObservability({
|
||||
const profile = observability?.profile ?? null;
|
||||
const latestMetric = observability?.metric_snapshots?.[0] ?? null;
|
||||
const activeHealthEvents = useMemo(
|
||||
() => observability?.health_events.filter((event) => event.status === 'active') ?? [],
|
||||
() =>
|
||||
observability?.health_events.filter(
|
||||
(event) => event.status === 'active',
|
||||
) ?? [],
|
||||
[observability?.health_events],
|
||||
);
|
||||
const resolvedHealthEvents = useMemo(
|
||||
() => observability?.health_events.filter((event) => event.status === 'resolved') ?? [],
|
||||
() =>
|
||||
observability?.health_events.filter(
|
||||
(event) => event.status === 'resolved',
|
||||
) ?? [],
|
||||
[observability?.health_events],
|
||||
);
|
||||
const filteredHealthEvents = useMemo(() => {
|
||||
@@ -331,14 +388,27 @@ export function NodeObservability({
|
||||
default:
|
||||
return observability?.health_events ?? [];
|
||||
}
|
||||
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
|
||||
}, [
|
||||
activeHealthEvents,
|
||||
healthEventFilter,
|
||||
observability?.health_events,
|
||||
resolvedHealthEvents,
|
||||
]);
|
||||
|
||||
const statusCodeDistribution = useMemo(
|
||||
() => aggregateTrafficBreakdown(observability?.traffic_reports, 'status_codes_json'),
|
||||
() =>
|
||||
aggregateTrafficBreakdown(
|
||||
observability?.traffic_reports,
|
||||
'status_codes_json',
|
||||
),
|
||||
[observability?.traffic_reports],
|
||||
);
|
||||
const topDomains = useMemo(
|
||||
() => aggregateTrafficBreakdown(observability?.traffic_reports, 'top_domains_json'),
|
||||
() =>
|
||||
aggregateTrafficBreakdown(
|
||||
observability?.traffic_reports,
|
||||
'top_domains_json',
|
||||
),
|
||||
[observability?.traffic_reports],
|
||||
);
|
||||
const trafficSummary = observability?.analytics?.traffic ?? null;
|
||||
@@ -361,9 +431,9 @@ export function NodeObservability({
|
||||
|
||||
if (observabilityQuery.isLoading) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardContent className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 mr-2 animate-spin' />
|
||||
加载运行观测数据中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -372,9 +442,11 @@ export function NodeObservability({
|
||||
|
||||
if (observabilityQuery.isError) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardContent className='py-6'>
|
||||
<p className='text-sm text-destructive'>
|
||||
{getErrorMessage(observabilityQuery.error)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -382,10 +454,10 @@ export function NodeObservability({
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<SummaryStat
|
||||
label="运行诊断"
|
||||
label='运行诊断'
|
||||
value={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
@@ -398,44 +470,52 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="系统核心"
|
||||
label='系统核心'
|
||||
value={profile?.hostname || '—'}
|
||||
hint={profile ? `${profile.os_name || '—'} · ${profile.architecture || '—'}` : '—'}
|
||||
hint={
|
||||
profile
|
||||
? `${profile.os_name || '—'} · ${profile.architecture || '—'}`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="在线时长"
|
||||
label='在线时长'
|
||||
value={formatUptime(profile?.uptime_seconds)}
|
||||
hint="来自节点系统画像上报"
|
||||
hint='来自节点系统画像上报'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="border-dashed shadow-none">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">系统画像</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
系统画像
|
||||
</CardTitle>
|
||||
<CardDescription>节点上报的主机与硬件信息</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile ? (
|
||||
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已接入,但尚未上报完整系统画像。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">实时资源快照</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
实时资源快照
|
||||
</CardTitle>
|
||||
<CardDescription>最近一次 metrics 上报</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{latestMetric ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<MetricBar
|
||||
label="CPU"
|
||||
label='CPU'
|
||||
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||
progress={latestMetric.cpu_usage_percent}
|
||||
hint={
|
||||
@@ -445,24 +525,28 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<MetricBar
|
||||
label="内存"
|
||||
label='内存'
|
||||
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
|
||||
progress={memoryUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label="存储"
|
||||
label='存储'
|
||||
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
|
||||
progress={storageUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label="连接数"
|
||||
value={formatMetricCount(latestMetric.openresty_connections)}
|
||||
label='连接数'
|
||||
value={formatMetricCount(
|
||||
latestMetric.openresty_connections,
|
||||
)}
|
||||
progress={null}
|
||||
hint={connectionHint}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">节点已接入,但尚未上报资源快照。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已接入,但尚未上报资源快照。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -486,9 +570,11 @@ export function NodeObservability({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={() => cleanupMutation.mutate()}
|
||||
>
|
||||
@@ -502,10 +588,10 @@ export function NodeObservability({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<SummaryStat
|
||||
label="运行诊断"
|
||||
label='运行诊断'
|
||||
value={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
@@ -518,7 +604,7 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="请求/分钟"
|
||||
label='请求/分钟'
|
||||
value={formatMetricCount(trafficSummary?.request_count)}
|
||||
hint={
|
||||
trafficSummary
|
||||
@@ -527,7 +613,7 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="容量压力"
|
||||
label='容量压力'
|
||||
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
|
||||
hint={
|
||||
latestMetric
|
||||
@@ -536,7 +622,7 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="来源信号"
|
||||
label='来源信号'
|
||||
value={topSourceCountry?.key ?? '—'}
|
||||
hint={
|
||||
topSourceCountry
|
||||
@@ -546,31 +632,31 @@ export function NodeObservability({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<Card className="border-dashed shadow-none">
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">系统信息</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>系统信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile ? (
|
||||
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报完整系统画像。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">实时资源</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>实时资源</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{latestMetric ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<MetricBar
|
||||
label="CPU"
|
||||
label='CPU'
|
||||
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||
progress={latestMetric.cpu_usage_percent}
|
||||
hint={
|
||||
@@ -580,37 +666,39 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<MetricBar
|
||||
label="内存"
|
||||
label='内存'
|
||||
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
|
||||
progress={memoryUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label="存储"
|
||||
label='存储'
|
||||
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
|
||||
progress={storageUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label="连接数"
|
||||
label='连接数'
|
||||
value={formatMetricCount(latestMetric.openresty_connections)}
|
||||
progress={null}
|
||||
hint={connectionHint}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">节点已经接入,但还没有上报资源快照。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报资源快照。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">网络流量</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>网络流量</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className='space-y-4'>
|
||||
{latestMetric ? (
|
||||
<>
|
||||
{node ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
@@ -630,49 +718,61 @@ export function NodeObservability({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
OpenResty 吞吐
|
||||
</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<p>入站:{formatBytesPerSecond(latestMetric.openresty_rx_bytes, 60)}</p>
|
||||
<p>出站:{formatBytesPerSecond(latestMetric.openresty_tx_bytes, 60)}</p>
|
||||
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
|
||||
<p>
|
||||
入站:
|
||||
{formatBytesPerSecond(
|
||||
latestMetric.openresty_rx_bytes,
|
||||
60,
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
出站:
|
||||
{formatBytesPerSecond(
|
||||
latestMetric.openresty_tx_bytes,
|
||||
60,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
节点网络
|
||||
</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
|
||||
<p>入站:{formatBytes(latestMetric.network_rx_bytes)}</p>
|
||||
<p>出站:{formatBytes(latestMetric.network_tx_bytes)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
请求/分钟
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{formatMetricCount(trafficSummary?.request_count)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{trafficSummary
|
||||
? `近 60 秒 · 窗口UV ${formatMetricCount(trafficSummary.unique_visitor_count)}`
|
||||
: '暂无窗口流量摘要'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
近 60 秒错误
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold">
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{formatMetricCount(trafficSummary?.error_count)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{trafficSummary
|
||||
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||
: '暂无错误率摘要'}
|
||||
@@ -681,7 +781,7 @@ export function NodeObservability({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报网络流量相关快照。
|
||||
</p>
|
||||
)}
|
||||
@@ -689,70 +789,92 @@ export function NodeObservability({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<TrafficTrendChart
|
||||
points={trends?.traffic_24h ?? []}
|
||||
description="按小时聚合该节点的请求量和错误量。"
|
||||
description='按小时聚合该节点的请求量和错误量。'
|
||||
/>
|
||||
<CapacityTrendChart
|
||||
points={trends?.capacity_24h ?? []}
|
||||
description="观察该节点 CPU 与内存使用率在 24 小时内的变化。"
|
||||
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NetworkTrendChart points={trends?.network_24h ?? []} />
|
||||
<DiskIOTrendChart points={trends?.disk_io_24h ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card className="border-dashed shadow-none">
|
||||
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">请求结构分布</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
请求结构分布
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
|
||||
聚合最近 24
|
||||
小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-lg border px-4 py-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">主状态码</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{dominantStatusCode?.label ?? '—'}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码分布'}
|
||||
<div className='mb-6 grid gap-4 md:grid-cols-3'>
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
主状态码
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{dominantStatusCode?.label ?? '—'}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{dominantStatusCode
|
||||
? `${dominantStatusCode.value} 次`
|
||||
: '暂无状态码分布'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border px-4 py-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Top Domain</p>
|
||||
<p className="mt-3 truncate text-2xl font-semibold">
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
Top Domain
|
||||
</p>
|
||||
<p className='mt-3 truncate text-2xl font-semibold'>
|
||||
{dominantDomain?.label ?? '—'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{dominantDomain ? `${dominantDomain.value} 次` : '暂无域名分布'}
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{dominantDomain
|
||||
? `${dominantDomain.value} 次`
|
||||
: '暂无域名分布'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border px-4 py-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">已恢复事件</p>
|
||||
<p className="mt-3 text-2xl font-semibold">{resolvedHealthEvents.length}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">最近 24 小时已恢复健康事件</p>
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
已恢复事件
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{resolvedHealthEvents.length}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
最近 24 小时已恢复健康事件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<div>
|
||||
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
状态码分布
|
||||
</p>
|
||||
<DistributionList
|
||||
items={statusCodeDistribution}
|
||||
emptyMessage="暂无状态码分布"
|
||||
emptyMessage='暂无状态码分布'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
Top Domain
|
||||
</p>
|
||||
<DistributionList items={topDomains} emptyMessage="暂无域名分布" />
|
||||
<DistributionList
|
||||
items={topDomains}
|
||||
emptyMessage='暂无域名分布'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -777,9 +899,11 @@ export function NodeObservability({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={() => cleanupMutation.mutate()}
|
||||
>
|
||||
@@ -790,4 +914,4 @@ export function NodeObservability({
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type {StatusTone} from './node-utils';
|
||||
import type { StatusTone } from './node-utils';
|
||||
|
||||
const toneClassName: Record<StatusTone, string> = {
|
||||
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
|
||||
@@ -21,8 +21,12 @@ export function NodeStatusBadge({
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'text-[10px] rounded-full py-0 px-2 font-medium',
|
||||
toneClassName[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
|
||||
|
||||
@@ -16,7 +16,12 @@ const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
|
||||
|
||||
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
|
||||
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
|
||||
if (current === 'relay' || current === 'tunnel' || current === 'edge' || current === 'all') {
|
||||
if (
|
||||
current === 'relay' ||
|
||||
current === 'tunnel' ||
|
||||
current === 'edge' ||
|
||||
current === 'all'
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return 'all';
|
||||
@@ -58,7 +63,7 @@ export function NodeTypeFilter() {
|
||||
const activeFilter = getNodeFilter(searchParams);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{filters.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type {ApplyResult, NodeItem, NodeStatus, NodeTrafficReport, OpenrestyStatus,} from '@/lib/services/openflare';
|
||||
import type {
|
||||
ApplyResult,
|
||||
NodeItem,
|
||||
NodeStatus,
|
||||
NodeTrafficReport,
|
||||
OpenrestyStatus,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||
export const FLARED_WS_CONNECTED_LAST_SEEN =
|
||||
'__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
|
||||
return (
|
||||
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
|
||||
);
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined): value is string {
|
||||
export function isMeaningfulTime(
|
||||
value: string | null | undefined,
|
||||
): value is string {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
!isWSConnectedLastSeen(value) &&
|
||||
@@ -75,7 +86,9 @@ export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
|
||||
return '未知';
|
||||
}
|
||||
|
||||
export function getRelayStatusTone(status: string | null | undefined): StatusTone {
|
||||
export function getRelayStatusTone(
|
||||
status: string | null | undefined,
|
||||
): StatusTone {
|
||||
if (status === 'healthy') return 'success';
|
||||
if (status === 'unhealthy') return 'danger';
|
||||
return 'warning';
|
||||
@@ -112,7 +125,12 @@ export function getImageTag(version?: string): string {
|
||||
return 'latest';
|
||||
}
|
||||
const v = version.toLowerCase();
|
||||
if (v === 'dev' || v.includes('alpha') || v.includes('beta') || v.includes('rc')) {
|
||||
if (
|
||||
v === 'dev' ||
|
||||
v.includes('alpha') ||
|
||||
v.includes('beta') ||
|
||||
v.includes('rc')
|
||||
) {
|
||||
return 'beta';
|
||||
}
|
||||
if (v.startsWith('v')) {
|
||||
@@ -121,7 +139,10 @@ export function getImageTag(version?: string): string {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
|
||||
export function buildRelayInstallCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
) {
|
||||
return [
|
||||
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
@@ -129,7 +150,11 @@ export function buildRelayInstallCommand(serverUrl: string, discoveryToken: stri
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken: string, version?: string) {
|
||||
export function buildRelayDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
version?: string,
|
||||
) {
|
||||
const tag = getImageTag(version);
|
||||
const image = `ghcr.io/rain-kl/openflare-relay:${tag}`;
|
||||
|
||||
@@ -143,7 +168,10 @@ export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) {
|
||||
export function buildTunnelInstallCommand(
|
||||
serverUrl: string,
|
||||
tunnelToken: string,
|
||||
) {
|
||||
return [
|
||||
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
@@ -151,7 +179,11 @@ export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken: string, version?: string) {
|
||||
export function buildTunnelDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
tunnelToken: string,
|
||||
version?: string,
|
||||
) {
|
||||
const tag = getImageTag(version);
|
||||
const image = `ghcr.io/rain-kl/openflared:${tag}`;
|
||||
|
||||
@@ -165,7 +197,6 @@ export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken:
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
export function formatBytes(bytes?: number | null, decimals = 1) {
|
||||
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
|
||||
return '—';
|
||||
@@ -175,7 +206,10 @@ export function formatBytes(bytes?: number | null, decimals = 1) {
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const index = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / 1024 ** index;
|
||||
return `${value.toFixed(decimals)} ${units[index]}`;
|
||||
}
|
||||
@@ -194,10 +228,7 @@ export function formatMetricCount(value?: number | null) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatBytesPerSecond(
|
||||
value?: number | null,
|
||||
windowSeconds = 1,
|
||||
) {
|
||||
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
|
||||
if (value === undefined || value === null || !Number.isFinite(value)) {
|
||||
return '—';
|
||||
}
|
||||
@@ -213,12 +244,15 @@ export function parseTrafficMap(value?: string | null) {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, number>;
|
||||
return Object.entries(parsed).reduce<Record<string, number>>((result, [key, count]) => {
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
result[key] = count;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
return Object.entries(parsed).reduce<Record<string, number>>(
|
||||
(result, [key, count]) => {
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
result[key] = count;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
@@ -271,9 +305,10 @@ export function formatUptime(seconds?: number | null) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
export function getHealthEventTone(
|
||||
event: { status: string; severity: string },
|
||||
): StatusTone {
|
||||
export function getHealthEventTone(event: {
|
||||
status: string;
|
||||
severity: string;
|
||||
}): StatusTone {
|
||||
if (event.status === 'resolved') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {Pencil, Trash2} from 'lucide-react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeItem} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import type { NodeItem } from '@/lib/services/openflare';
|
||||
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getApplyLabel,
|
||||
@@ -36,48 +43,51 @@ export function NodesTable({
|
||||
onDelete: (node: NodeItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg overflow-hidden">
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="py-2 h-8">节点</TableHead>
|
||||
<TableHead className="py-2 h-8">状态</TableHead>
|
||||
<TableHead className="py-2 h-8">Version</TableHead>
|
||||
<TableHead className="py-2 h-8">运行健康</TableHead>
|
||||
<TableHead className="py-2 h-8">当前版本</TableHead>
|
||||
<TableHead className="py-2 h-8">最近应用</TableHead>
|
||||
<TableHead className="py-2 h-8">最近心跳</TableHead>
|
||||
<TableHead className="py-2 h-8 text-right">操作</TableHead>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='py-2 h-8'>节点</TableHead>
|
||||
<TableHead className='py-2 h-8'>状态</TableHead>
|
||||
<TableHead className='py-2 h-8'>Version</TableHead>
|
||||
<TableHead className='py-2 h-8'>运行健康</TableHead>
|
||||
<TableHead className='py-2 h-8'>当前版本</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近应用</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近心跳</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nodes.map((node) => (
|
||||
<TableRow key={node.id} className="border-dashed align-top">
|
||||
<TableCell className="py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{node.name}</p>
|
||||
<NodeStatusBadge label={getNodeTypeLabel(node.node_type)} tone="info" />
|
||||
<TableRow key={node.id} className='border-dashed align-top'>
|
||||
<TableCell className='py-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='font-medium'>{node.name}</p>
|
||||
<NodeStatusBadge
|
||||
label={getNodeTypeLabel(node.node_type)}
|
||||
tone='info'
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
IP:{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<TableCell className='py-3'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
<TableCell className='py-3 text-muted-foreground'>
|
||||
{node.version || 'unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<TableCell className='py-3'>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<NodeStatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
@@ -95,13 +105,13 @@ export function NodesTable({
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
<TableCell className='py-3 text-muted-foreground'>
|
||||
{node.current_version ||
|
||||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<TableCell className='py-3'>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
<span className='text-sm text-muted-foreground'>—</span>
|
||||
) : (
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
@@ -109,35 +119,40 @@ export function NodesTable({
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
<TableCell className='py-3 text-muted-foreground'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<TableCell className='py-3'>
|
||||
<div className='flex flex-wrap justify-end gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => onEdit(node)}
|
||||
>
|
||||
<Pencil className="size-3 mr-1" />
|
||||
<Pencil className='size-3 mr-1' />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs text-destructive hover:text-destructive'
|
||||
disabled={deletingId === node.id}
|
||||
onClick={() => onDelete(node)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
<Trash2 className='size-3 mr-1' />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Copy,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -30,23 +30,31 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeItem,
|
||||
ReleaseChannel,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
import services from '@/lib/services';
|
||||
import type {SystemConfig} from '@/lib/services/admin';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
|
||||
import {AgentUpdateDialog} from './agent-update-dialog';
|
||||
import {InstallCommand} from './install-command';
|
||||
import {NodeDetailShell} from './node-detail-shell';
|
||||
import {NodeErrorBanner, NodeInfoRow, NodeSectionCard,} from './node-detail-primitives';
|
||||
import {NodeEditorDialog} from './node-editor-dialog';
|
||||
import {NodeObservability} from './node-observability';
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
import { AgentUpdateDialog } from './agent-update-dialog';
|
||||
import { InstallCommand } from './install-command';
|
||||
import { NodeDetailShell } from './node-detail-shell';
|
||||
import {
|
||||
NodeErrorBanner,
|
||||
NodeInfoRow,
|
||||
NodeSectionCard,
|
||||
} from './node-detail-primitives';
|
||||
import { NodeEditorDialog } from './node-editor-dialog';
|
||||
import { NodeObservability } from './node-observability';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getApplyLabel,
|
||||
@@ -85,7 +93,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
const configs = useMemo(
|
||||
() => systemConfigMap(systemConfigsQuery.data ?? []),
|
||||
[systemConfigsQuery.data]
|
||||
[systemConfigsQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,16 +109,22 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
throw new Error('端口号必须在 1 ~ 65535 范围内');
|
||||
}
|
||||
const portCfg = configs['relay_frps_web_ui_port'];
|
||||
await services.adminSystemConfig.updateSystemConfig('relay_frps_web_ui_port', {
|
||||
value: String(portNum),
|
||||
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
|
||||
});
|
||||
await services.adminSystemConfig.updateSystemConfig(
|
||||
'relay_frps_web_ui_port',
|
||||
{
|
||||
value: String(portNum),
|
||||
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'system-configs'] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('FRPS WebUI 端口配置已更新');
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '更新端口失败'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '更新端口失败'),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -166,7 +180,10 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
}) =>
|
||||
NodeService.requestAgentUpdate(node.id, {
|
||||
channel: release?.channel ?? channel,
|
||||
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
|
||||
tag_name:
|
||||
release?.channel === 'preview'
|
||||
? release.tag_name || undefined
|
||||
: undefined,
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
@@ -191,14 +208,20 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
const handleRefresh = () => {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'node-observability', node.id],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const webServerPortNum = parseInt(configs['relay_frps_web_ui_port']?.value || '', 10);
|
||||
const resolvedPort = !isNaN(webServerPortNum) && webServerPortNum > 0
|
||||
? webServerPortNum
|
||||
: (node.relay_bind_port || 7000) + 500;
|
||||
const webServerPortNum = parseInt(
|
||||
configs['relay_frps_web_ui_port']?.value || '',
|
||||
10,
|
||||
);
|
||||
const resolvedPort =
|
||||
!isNaN(webServerPortNum) && webServerPortNum > 0
|
||||
? webServerPortNum
|
||||
: (node.relay_bind_port || 7000) + 500;
|
||||
|
||||
const webUiUrl =
|
||||
node.relay_web_server_enabled && node.relay_bind_port
|
||||
@@ -207,69 +230,88 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
disabled={forceSyncMutation.isPending}
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className="size-3.5 mr-1.5" />
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
|
||||
<Upload className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 Relay'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 text-destructive hover:text-destructive'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<NodeSectionCard title="中继运行状态" description="frps 在线情况与负载摘要">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="运行状态">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='中继运行状态'
|
||||
description='frps 在线情况与负载摘要'
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="中继健康">
|
||||
<NodeInfoRow label='中继健康'>
|
||||
<NodeStatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
tone={getRelayStatusTone(node.relay_status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近心跳">
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="活动连接 / 代理数">
|
||||
{node.relay_frps_connections ?? '—'} / {node.relay_frps_proxy_count ?? '—'}
|
||||
<NodeInfoRow label='活动连接 / 代理数'>
|
||||
{node.relay_frps_connections ?? '—'} /{' '}
|
||||
{node.relay_frps_proxy_count ?? '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="IP 地址">
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</NodeInfoRow>
|
||||
@@ -277,31 +319,43 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title="网络端口"
|
||||
description="frps 控制面与 HTTP 虚拟主机配置"
|
||||
title='网络端口'
|
||||
description='frps 控制面与 HTTP 虚拟主机配置'
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="h-8" asChild>
|
||||
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
|
||||
<FileText className="size-3.5 mr-1.5" />
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="绑定控制端口">{node.relay_bind_port || '—'}</NodeInfoRow>
|
||||
<NodeInfoRow label="VHost HTTP 端口">{node.relay_vhost_http_port || '—'}</NodeInfoRow>
|
||||
<NodeInfoRow label="Agent 接入地址">
|
||||
<span className="break-all">{node.relay_agent_access_addr || '—'}</span>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='绑定控制端口'>
|
||||
{node.relay_bind_port || '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近应用">
|
||||
<NodeInfoRow label='VHost HTTP 端口'>
|
||||
{node.relay_vhost_http_port || '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Agent 接入地址'>
|
||||
<span className='break-all'>
|
||||
{node.relay_agent_access_addr || '—'}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="Relay 版本">{node.version || 'unknown'}</NodeInfoRow>
|
||||
<NodeInfoRow label="frps 核心版本">{node.ext_version || 'unknown'}</NodeInfoRow>
|
||||
<NodeInfoRow label='Relay 版本'>
|
||||
{node.version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='frps 核心版本'>
|
||||
{node.ext_version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
@@ -309,13 +363,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className="space-y-6">
|
||||
<NodeSectionCard title="FRPS WebUI" description="控制 frps 内置 Web 管理界面是否启用及其监听端口">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-xl border px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className='space-y-6'>
|
||||
<NodeSectionCard
|
||||
title='FRPS WebUI'
|
||||
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between rounded-xl border px-4 py-4'>
|
||||
<div className='space-y-1'>
|
||||
<Label>启用 Web 管理界面</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
开启后,节点将启动 frps 的内置管理服务。
|
||||
</p>
|
||||
</div>
|
||||
@@ -326,80 +383,93 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-4 space-y-3">
|
||||
<Label htmlFor="web_server_port" className="text-xs font-semibold">Web 管理界面端口</Label>
|
||||
<div className="flex max-w-sm items-center gap-2">
|
||||
<div className='rounded-xl border p-4 space-y-3'>
|
||||
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
|
||||
Web 管理界面端口
|
||||
</Label>
|
||||
<div className='flex max-w-sm items-center gap-2'>
|
||||
<Input
|
||||
id="web_server_port"
|
||||
type="number"
|
||||
id='web_server_port'
|
||||
type='number'
|
||||
min={1}
|
||||
max={65535}
|
||||
value={webServerPort}
|
||||
onChange={(e) => setWebServerPort(e.target.value)}
|
||||
placeholder="例如: 17500"
|
||||
className="bg-card text-xs h-9"
|
||||
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
|
||||
placeholder='例如: 17500'
|
||||
className='bg-card text-xs h-9'
|
||||
disabled={
|
||||
savePortMutation.isPending || systemConfigsQuery.isLoading
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9"
|
||||
size='sm'
|
||||
className='h-9'
|
||||
onClick={() => savePortMutation.mutate(webServerPort)}
|
||||
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
|
||||
disabled={
|
||||
savePortMutation.isPending || systemConfigsQuery.isLoading
|
||||
}
|
||||
>
|
||||
{savePortMutation.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
当前实际监听端口:{resolvedPort} (修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
当前实际监听端口:{resolvedPort}{' '}
|
||||
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{webUiUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<div className='flex flex-wrap items-center gap-3 text-sm'>
|
||||
<a
|
||||
href={webUiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center font-medium text-primary hover:underline"
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center font-medium text-primary hover:underline'
|
||||
>
|
||||
打开 FRPS WebUI
|
||||
<ExternalLink className="size-3.5 ml-1.5" />
|
||||
<ExternalLink className='size-3.5 ml-1.5' />
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border">
|
||||
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'>
|
||||
{webUiUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(webUiUrl);
|
||||
toast.success('链接已复制到剪贴板');
|
||||
}}
|
||||
title="复制链接"
|
||||
title='复制链接'
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
<Copy className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">WebUI 已禁用或未配置绑定端口。</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
WebUI 已禁用或未配置绑定端口。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<InstallCommand node={node} variant="relay" />
|
||||
<InstallCommand node={node} variant='relay' />
|
||||
|
||||
<NodeSectionCard title="节点元数据">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="节点 ID">
|
||||
<span className="font-mono text-xs break-all">{node.node_id}</span>
|
||||
<NodeSectionCard title='节点元数据'>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
|
||||
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
@@ -409,10 +479,13 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel="Relay"
|
||||
typeTone="warning"
|
||||
typeLabel='Relay'
|
||||
typeTone='warning'
|
||||
statusBadges={[
|
||||
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getRelayStatusLabel(node.relay_status),
|
||||
tone: getRelayStatusTone(node.relay_status),
|
||||
@@ -421,8 +494,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
actions={headerActions}
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: 'Relay 版本', value: node.version || 'unknown', icon: Server },
|
||||
{ label: 'frps 核心', value: node.ext_version || 'unknown', icon: Network },
|
||||
{
|
||||
label: 'Relay 版本',
|
||||
value: node.version || 'unknown',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
label: 'frps 核心',
|
||||
value: node.ext_version || 'unknown',
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
value: isWSConnectedLastSeen(node.last_seen_at)
|
||||
@@ -437,12 +518,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
dashboard={
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant="compact"
|
||||
connectionHint="中继承载活动连接数"
|
||||
variant='compact'
|
||||
connectionHint='中继承载活动连接数'
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
defaultTab="overview"
|
||||
defaultTab='overview'
|
||||
/>
|
||||
|
||||
<NodeEditorDialog
|
||||
@@ -470,13 +551,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除中继节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
|
||||
确认删除中继节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useState} from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
FileText,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -27,22 +27,26 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeItem,
|
||||
ReleaseChannel,
|
||||
} from '@/lib/services/openflare';
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
|
||||
import {AgentUpdateDialog} from './agent-update-dialog';
|
||||
import {InstallCommand} from './install-command';
|
||||
import {NodeDetailShell} from './node-detail-shell';
|
||||
import { AgentUpdateDialog } from './agent-update-dialog';
|
||||
import { InstallCommand } from './install-command';
|
||||
import { NodeDetailShell } from './node-detail-shell';
|
||||
import {
|
||||
NodeErrorBanner,
|
||||
NodeInfoRow,
|
||||
NodeSectionCard,
|
||||
} from './node-detail-primitives';
|
||||
import {NodeEditorDialog} from './node-editor-dialog';
|
||||
import {NodeObservability} from './node-observability';
|
||||
import {NodeStatusBadge} from './node-status-badge';
|
||||
import { NodeEditorDialog } from './node-editor-dialog';
|
||||
import { NodeObservability } from './node-observability';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getApplyLabel,
|
||||
@@ -95,7 +99,10 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
}) =>
|
||||
NodeService.requestAgentUpdate(node.id, {
|
||||
channel: release?.channel ?? channel,
|
||||
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
|
||||
tag_name:
|
||||
release?.channel === 'preview'
|
||||
? release.tag_name || undefined
|
||||
: undefined,
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
@@ -120,108 +127,136 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
const handleRefresh = () => {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'node-observability', node.id],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
disabled={forceSyncMutation.isPending}
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className="size-3.5 mr-1.5" />
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
|
||||
<Upload className="size-3.5 mr-1.5" />
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8'
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 openflared'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 text-destructive hover:text-destructive'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<NodeSectionCard title="隧道运行状态" description="openflared 连接与在线情况">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="运行状态">
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='隧道运行状态'
|
||||
description='openflared 连接与在线情况'
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="flared 状态">
|
||||
<NodeInfoRow label='flared 状态'>
|
||||
<NodeStatusBadge
|
||||
label={getFlaredStatusLabel(node)}
|
||||
tone={getFlaredStatusTone(node)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近心跳">
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WebSocket 长连接已建立'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="IP 地址">
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="自动更新">{node.auto_update_enabled ? '已启用' : '手动'}</NodeInfoRow>
|
||||
<NodeInfoRow label='自动更新'>
|
||||
{node.auto_update_enabled ? '已启用' : '手动'}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title="配置同步"
|
||||
description="隧道客户端配置追平状态"
|
||||
title='配置同步'
|
||||
description='隧道客户端配置追平状态'
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="h-8" asChild>
|
||||
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
|
||||
<FileText className="size-3.5 mr-1.5" />
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="当前配置版本">{node.current_version || '未应用'}</NodeInfoRow>
|
||||
<NodeInfoRow label="最近应用">
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='当前配置版本'>
|
||||
{node.current_version || '未应用'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="最近应用时间">
|
||||
<NodeInfoRow label='最近应用时间'>
|
||||
{isMeaningfulTime(node.latest_apply_at)
|
||||
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: '暂无'}
|
||||
</NodeInfoRow>
|
||||
{node.latest_apply_checksum ? (
|
||||
<NodeInfoRow label="同步文件数">{node.latest_support_file_count} 个</NodeInfoRow>
|
||||
<NodeInfoRow label='同步文件数'>
|
||||
{node.latest_support_file_count} 个
|
||||
</NodeInfoRow>
|
||||
) : null}
|
||||
<NodeInfoRow label="升级状态">
|
||||
<NodeInfoRow label='升级状态'>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
node.update_requested
|
||||
@@ -240,21 +275,27 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className="space-y-6">
|
||||
<NodeSectionCard title="接入凭证" description="隧道客户端接入所需 Token">
|
||||
<div className="divide-y">
|
||||
<NodeInfoRow label="Tunnel Token">
|
||||
<span className="font-mono text-xs break-all">{node.access_token || '暂无'}</span>
|
||||
<div className='space-y-6'>
|
||||
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='Tunnel Token'>
|
||||
<span className='font-mono text-xs break-all'>
|
||||
{node.access_token || '暂无'}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="节点 ID">
|
||||
<span className="font-mono text-xs break-all">{node.node_id}</span>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
|
||||
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<InstallCommand node={node} variant="tunnel" />
|
||||
<InstallCommand node={node} variant='tunnel' />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -262,17 +303,31 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel="Tunnel"
|
||||
typeTone="info"
|
||||
typeLabel='Tunnel'
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
|
||||
{ label: getFlaredStatusLabel(node), tone: getFlaredStatusTone(node) },
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getFlaredStatusLabel(node),
|
||||
tone: getFlaredStatusTone(node),
|
||||
},
|
||||
]}
|
||||
actions={headerActions}
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: 'openflared', value: node.version || 'unknown', icon: Package },
|
||||
{ label: '当前配置', value: node.current_version || '未应用', icon: KeyRound },
|
||||
{
|
||||
label: 'openflared',
|
||||
value: node.version || 'unknown',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
label: '当前配置',
|
||||
value: node.current_version || '未应用',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
value: isWSConnectedLastSeen(node.last_seen_at)
|
||||
@@ -287,12 +342,12 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
dashboard={
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant="compact"
|
||||
connectionHint="隧道并发连接数"
|
||||
variant='compact'
|
||||
connectionHint='隧道并发连接数'
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
defaultTab="overview"
|
||||
defaultTab='overview'
|
||||
/>
|
||||
|
||||
<NodeEditorDialog
|
||||
@@ -320,13 +375,16 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除隧道节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
|
||||
确认删除隧道节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
@@ -337,4 +395,4 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {ArrowLeft, Server} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Server } from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
|
||||
import {EdgeNodeDetail} from '../components/edge-node-detail';
|
||||
import {RelayNodeDetail} from '../components/relay-node-detail';
|
||||
import {TunnelNodeDetail} from '../components/tunnel-node-detail';
|
||||
import {getErrorMessage} from '../components/node-utils';
|
||||
import { EdgeNodeDetail } from '../components/edge-node-detail';
|
||||
import { RelayNodeDetail } from '../components/relay-node-detail';
|
||||
import { TunnelNodeDetail } from '../components/tunnel-node-detail';
|
||||
import { getErrorMessage } from '../components/node-utils';
|
||||
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
@@ -31,15 +31,17 @@ export function NodeDetailPageClient() {
|
||||
|
||||
const node = useMemo(() => {
|
||||
if (!nodeId) return null;
|
||||
return (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null;
|
||||
return (
|
||||
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
|
||||
);
|
||||
}, [nodeId, nodesQuery.data]);
|
||||
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description="缺少节点 ID,请从节点列表进入详情页。"
|
||||
description='缺少节点 ID,请从节点列表进入详情页。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -47,20 +49,20 @@ export function NodeDetailPageClient() {
|
||||
|
||||
if (nodesQuery.isLoading) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Server} description="加载节点详情中..." />
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodesQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='py-6 px-1'>
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
onRetry={() => void nodesQuery.refetch()}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,16 +71,16 @@ export function NodeDetailPageClient() {
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-4">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/nodes">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
<div className='py-6 px-1 space-y-4'>
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
|
||||
<Link href='/nodes'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回节点列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description="节点不存在,可能已被删除或 ID 无效。"
|
||||
description='节点不存在,可能已被删除或 ID 无效。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {Suspense} from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import {NodeDetailPageClient} from './page-client';
|
||||
import { NodeDetailPageClient } from './page-client';
|
||||
|
||||
function NodeDetailPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full max-w-xl" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<Skeleton className='h-8 w-48' />
|
||||
<Skeleton className='h-10 w-full max-w-xl' />
|
||||
<Skeleton className='h-64 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,4 +20,4 @@ export default function NodeDetailPage() {
|
||||
<NodeDetailPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {Loader2, Plus, RefreshCw, Server} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -17,18 +17,29 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {NodeItem, NodeMutationPayload} from '@/lib/services/openflare';
|
||||
import {NodeService} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
|
||||
import {NodeEditorDialog} from './components/node-editor-dialog';
|
||||
import {filterNodesByType, getFilterDescription, getNodeFilter, NodeTypeFilter,} from './components/node-type-filter';
|
||||
import {NodesTable} from './components/nodes-table';
|
||||
import {getErrorMessage} from './components/node-utils';
|
||||
import { NodeEditorDialog } from './components/node-editor-dialog';
|
||||
import {
|
||||
filterNodesByType,
|
||||
getFilterDescription,
|
||||
getNodeFilter,
|
||||
NodeTypeFilter,
|
||||
} from './components/node-type-filter';
|
||||
import { NodesTable } from './components/nodes-table';
|
||||
import { getErrorMessage } from './components/node-utils';
|
||||
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
@@ -101,68 +112,86 @@ export function NodesPageClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">节点管理</h1>
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Server className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>节点管理</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/apply-logs">应用记录</Link>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
|
||||
<Link href='/apply-logs'>应用记录</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">节点列表</CardTitle>
|
||||
<CardDescription>{getFilterDescription(nodeFilter)}</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
节点列表
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getFilterDescription(nodeFilter)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={handleRefresh}
|
||||
disabled={nodesQuery.isFetching}
|
||||
>
|
||||
{nodesQuery.isFetching ? (
|
||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
||||
<Loader2 className='size-3.5 mr-1 animate-spin' />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1" />
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className='space-y-4'>
|
||||
<NodeTypeFilter />
|
||||
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Server} description="加载节点列表中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description='加载节点列表中...'
|
||||
/>
|
||||
) : nodesQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
onRetry={handleRefresh}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description={nodes.length === 0 ? '暂无节点,请先创建一个节点。' : '当前筛选无结果'}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '暂无节点,请先创建一个节点。'
|
||||
: '当前筛选无结果'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<NodesTable
|
||||
nodes={filteredNodes}
|
||||
deletingId={deleteMutation.isPending ? deleteTarget?.id ?? null : null}
|
||||
deletingId={
|
||||
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
@@ -183,20 +212,28 @@ export function NodesPageClient() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。
|
||||
确认删除节点「{deleteTarget?.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {Suspense} from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import {NodesPageClient} from './page-client';
|
||||
import { NodesPageClient } from './page-client';
|
||||
|
||||
function NodesPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-7 w-20" />
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-8 w-40' />
|
||||
<div className='flex gap-2'>
|
||||
<Skeleton className='h-7 w-20' />
|
||||
<Skeleton className='h-7 w-24' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
<Skeleton className='h-96 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,4 +25,4 @@ export default function NodesPage() {
|
||||
<NodesPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {zodResolver} from "@hookform/resolvers/zod"
|
||||
import {useForm} from "react-hook-form"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import {z} from "zod"
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,36 +16,40 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {type OriginItem, type OriginMutationPayload, OriginService} from "@/lib/services/openflare"
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
type OriginItem,
|
||||
type OriginMutationPayload,
|
||||
OriginService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请输入源站地址")
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes("://"),
|
||||
"源站地址格式不合法",
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255),
|
||||
})
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
|
||||
const originsQueryKey = ["openflare", "origins"] as const
|
||||
const originsQueryKey = ['openflare', 'origins'] as const;
|
||||
|
||||
function toFormValues(origin?: OriginItem | null): OriginFormValues {
|
||||
if (!origin) return { name: "", address: "", remark: "" }
|
||||
if (!origin) return { name: '', address: '', remark: '' };
|
||||
return {
|
||||
name: origin.name,
|
||||
address: origin.address,
|
||||
remark: origin.remark || "",
|
||||
}
|
||||
remark: origin.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
@@ -53,14 +57,14 @@ function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
name: values.name.trim(),
|
||||
address: values.address.trim(),
|
||||
remark: values.remark.trim(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface OriginEditorDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
origin?: OriginItem | null
|
||||
onSaved?: () => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
origin?: OriginItem | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function OriginEditorDialog({
|
||||
@@ -69,99 +73,103 @@ export function OriginEditorDialog({
|
||||
origin,
|
||||
onSaved,
|
||||
}: OriginEditorDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) form.reset(toFormValues(origin))
|
||||
}, [form, origin, open])
|
||||
if (open) form.reset(toFormValues(origin));
|
||||
}, [form, origin, open]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OriginFormValues) => {
|
||||
const payload = toPayload(values)
|
||||
const payload = toPayload(values);
|
||||
return origin
|
||||
? OriginService.update(origin.id, payload)
|
||||
: OriginService.create(payload)
|
||||
: OriginService.create(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(origin ? "源站已更新" : "源站已创建")
|
||||
await queryClient.invalidateQueries({ queryKey: originsQueryKey })
|
||||
toast.success(origin ? '源站已更新' : '源站已创建');
|
||||
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
|
||||
if (origin) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["openflare", "origins", String(origin.id)],
|
||||
})
|
||||
queryKey: ['openflare', 'origins', String(origin.id)],
|
||||
});
|
||||
}
|
||||
onSaved?.()
|
||||
onOpenChange(false)
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{origin ? "编辑源站" : "新增源站"}</DialogTitle>
|
||||
<DialogTitle>{origin ? '编辑源站' : '新增源站'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
源站作为规则里的可复用地址目录,协议和端口仍由规则决定。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="origin-editor-form"
|
||||
className="space-y-4"
|
||||
id='origin-editor-form'
|
||||
className='space-y-4'
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="address">源站地址</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='address'>源站地址</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="origin.internal"
|
||||
{...form.register("address")}
|
||||
id='address'
|
||||
placeholder='origin.internal'
|
||||
{...form.register('address')}
|
||||
/>
|
||||
{form.formState.errors.address ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.address.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">源站名</Label>
|
||||
<Input id="name" placeholder="主站源站" {...form.register("name")} />
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='name'>源站名</Label>
|
||||
<Input
|
||||
id='name'
|
||||
placeholder='主站源站'
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Textarea id="remark" rows={2} {...form.register("remark")} />
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='remark'>备注</Label>
|
||||
<Textarea id='remark' rows={2} {...form.register('remark')} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="origin-editor-form"
|
||||
type='submit'
|
||||
form='origin-editor-form'
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
保存中...
|
||||
</>
|
||||
) : origin ? (
|
||||
"保存修改"
|
||||
'保存修改'
|
||||
) : (
|
||||
"新增源站"
|
||||
'新增源站'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import Link from "next/link"
|
||||
import {useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {useSearchParams} from "next/navigation"
|
||||
import {ArrowLeft, MapPin, Trash2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, MapPin, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,176 +16,211 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {OriginService} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { OriginService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {OriginEditorDialog} from "../components/origin-editor-dialog"
|
||||
import { OriginEditorDialog } from '../components/origin-editor-dialog';
|
||||
|
||||
export function OriginDetailPageClient() {
|
||||
const searchParams = useSearchParams()
|
||||
const queryClient = useQueryClient()
|
||||
const originId = searchParams.get("id")?.trim() ?? ""
|
||||
const parsedId = Number(originId)
|
||||
const enabled = originId !== "" && Number.isFinite(parsedId)
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const originId = searchParams.get('id')?.trim() ?? '';
|
||||
const parsedId = Number(originId);
|
||||
const enabled = originId !== '' && Number.isFinite(parsedId);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const originQuery = useQuery({
|
||||
queryKey: ["openflare", "origins", originId],
|
||||
queryKey: ['openflare', 'origins', originId],
|
||||
queryFn: () => OriginService.getById(parsedId),
|
||||
enabled,
|
||||
})
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => OriginService.deleteById(parsedId),
|
||||
onSuccess: async () => {
|
||||
toast.success("源站已删除")
|
||||
await queryClient.invalidateQueries({ queryKey: ["openflare", "origins"] })
|
||||
window.location.href = "/origins"
|
||||
toast.success('源站已删除');
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'origins'],
|
||||
});
|
||||
window.location.href = '/origins';
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败")
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const origin = originQuery.data
|
||||
const origin = originQuery.data;
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder description="缺少有效的源站 ID。" />
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder description='缺少有效的源站 ID。' />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (originQuery.isLoading) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={MapPin} description="加载源站详情..." />
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={MapPin} description='加载源站详情...' />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (originQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<div className='py-6 px-1'>
|
||||
<ErrorInline
|
||||
message={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void originQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!origin) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/origins">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
<div className='py-6 px-1 space-y-4'>
|
||||
<Button variant='ghost' size='sm' asChild>
|
||||
<Link href='/origins'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder description="源站不存在或已被删除。" />
|
||||
<EmptyStateWithBorder description='源站不存在或已被删除。' />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 -ml-2" asChild>
|
||||
<Link href="/origins">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
|
||||
<Link href='/origins'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{origin.name}</h1>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{origin.name}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{origin.address}</p>
|
||||
<p className='text-sm text-muted-foreground'>{origin.address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
删除源站
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">绑定规则</p>
|
||||
<Badge variant="outline" className="mt-2 text-[10px]">
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
绑定规则
|
||||
</p>
|
||||
<Badge variant='outline' className='mt-2 text-[10px]'>
|
||||
{origin.route_count} 条规则
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">创建时间</p>
|
||||
<p className="mt-2 text-sm">{formatDateTime(origin.created_at)}</p>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
创建时间
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新时间</p>
|
||||
<p className="mt-2 text-sm">{formatDateTime(origin.updated_at)}</p>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
更新时间
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{origin.remark || "暂无备注"}</p>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
备注
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-dashed rounded-lg overflow-hidden bg-background">
|
||||
<div className="px-4 py-3 border-b border-dashed">
|
||||
<h2 className="text-sm font-semibold">关联规则</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
|
||||
<div className='px-4 py-3 border-b border-dashed'>
|
||||
<h2 className='text-sm font-semibold'>关联规则</h2>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
展示当前源站作为主源站绑定的规则。
|
||||
</p>
|
||||
</div>
|
||||
{origin.routes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title="暂无关联规则"
|
||||
description="当前源站还没有被任何规则引用。"
|
||||
title='暂无关联规则'
|
||||
description='当前源站还没有被任何规则引用。'
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold">更新时间</TableHead>
|
||||
<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>
|
||||
<TableHead className='text-xs font-semibold'>状态</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
更新时间
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{origin.routes.map((route) => (
|
||||
<TableRow key={route.id} className="border-dashed">
|
||||
<TableCell className="text-xs font-medium">{route.domain}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableRow key={route.id} className='border-dashed'>
|
||||
<TableCell className='text-xs font-medium'>
|
||||
{route.domain}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{route.origin_url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{route.enabled ? "启用" : "停用"}
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{route.enabled ? '启用' : '停用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{formatDateTime(route.updated_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -213,7 +248,7 @@ export function OriginDetailPageClient() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
确认删除
|
||||
@@ -222,5 +257,5 @@ export function OriginDetailPageClient() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user