mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
migrate frontend
This commit is contained in:
@@ -28,6 +28,8 @@ import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
||||
import {NodeService, OptionService, StatusService, UpdateService, UptimeKumaService,} from "@/lib/services/openflare"
|
||||
import {VersionUpgradeDialog} from "@/app/(main)/openflare/components/version-upgrade-dialog"
|
||||
import {openflareLatestReleaseQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
|
||||
|
||||
import {
|
||||
agentOptionEntries,
|
||||
@@ -82,6 +84,7 @@ export function OpenFlareOpsSettings() {
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
@@ -89,7 +92,7 @@ export function OpenFlareOpsSettings() {
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["openflare", "public-status"],
|
||||
queryKey: openflarePublicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
})
|
||||
|
||||
@@ -99,7 +102,7 @@ export function OpenFlareOpsSettings() {
|
||||
})
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: ["openflare", "latest-release"],
|
||||
queryKey: openflareLatestReleaseQueryKey("stable"),
|
||||
queryFn: () => UpdateService.getLatestRelease("stable"),
|
||||
})
|
||||
|
||||
@@ -602,11 +605,16 @@ export function OpenFlareOpsSettings() {
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
可通过 UpdateService 对接顶栏升级入口;完整升级流程可复用 Wavelet admin/updater。
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
检查 GitHub Release、在线升级或手动上传服务端二进制包。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
|
||||
管理升级
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
||||
@@ -635,6 +643,12 @@ export function OpenFlareOpsSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VersionUpgradeDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
canUpgrade
|
||||
/>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
open={uptimeKumaModalOpen}
|
||||
selectedSites={
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Info} from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {AboutService, StatusService} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
const aboutQueryKey = ["openflare", "about"] as const
|
||||
const publicStatusQueryKey = ["openflare", "public-status"] as const
|
||||
|
||||
function AboutPageSkeleton() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-md" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const aboutQuery = useQuery({
|
||||
queryKey: aboutQueryKey,
|
||||
queryFn: () => AboutService.getAboutContent(),
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: publicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
})
|
||||
|
||||
const isLoading = aboutQuery.isLoading || statusQuery.isLoading
|
||||
|
||||
if (isLoading) {
|
||||
return <AboutPageSkeleton />
|
||||
}
|
||||
|
||||
if (aboutQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={
|
||||
aboutQuery.error instanceof Error
|
||||
? aboutQuery.error.message
|
||||
: "关于内容加载失败"
|
||||
}
|
||||
onRetry={() => void aboutQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={
|
||||
statusQuery.error instanceof Error
|
||||
? statusQuery.error.message
|
||||
: "系统状态加载失败"
|
||||
}
|
||||
onRetry={() => void statusQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const aboutContent = aboutQuery.data?.trim() ?? ""
|
||||
const status = statusQuery.data
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
title="暂无系统信息"
|
||||
description="未能获取 OpenFlare 的公开状态信息。"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">关于 OpenFlare</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
公开展示当前系统简介、版本信息与项目入口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">系统信息</CardTitle>
|
||||
<CardDescription>当前运行实例的公开状态与项目链接。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{status.version || "dev"}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
<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 font-medium">
|
||||
{status.system_name || "OpenFlare"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Server 启动时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{formatDateTime(new Date(status.start_time * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
项目仓库
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Rain-kl/OpenFlare"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block text-sm font-medium text-primary transition hover:opacity-80"
|
||||
>
|
||||
github.com/Rain-kl/OpenFlare
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{aboutContent ? (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">项目介绍</CardTitle>
|
||||
<CardDescription>
|
||||
以下内容由系统设置中的「关于内容」维护。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={[
|
||||
"prose prose-sm max-w-none dark:prose-invert",
|
||||
"prose-p:text-foreground prose-p:leading-7",
|
||||
"prose-li:text-foreground",
|
||||
"prose-strong:text-foreground",
|
||||
"prose-a:text-primary",
|
||||
].join(" ")}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{aboutContent}</ReactMarkdown>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyStateWithBorder
|
||||
title="尚未配置关于内容"
|
||||
description="可在设置页的「其他设置」标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/openflare" className="text-primary transition hover:opacity-80">
|
||||
返回总览
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -394,7 +394,18 @@ export default function AccessLogsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{foldsQuery.isLoading ? (
|
||||
{foldsQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorInline
|
||||
message={
|
||||
foldsQuery.error instanceof Error
|
||||
? foldsQuery.error.message
|
||||
: "加载失败"
|
||||
}
|
||||
onRetry={() => void foldsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : foldsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||
@@ -455,7 +466,18 @@ export default function AccessLogsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{ipSummaryQuery.isLoading ? (
|
||||
{ipSummaryQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorInline
|
||||
message={
|
||||
ipSummaryQuery.error instanceof Error
|
||||
? ipSummaryQuery.error.message
|
||||
: "加载失败"
|
||||
}
|
||||
onRetry={() => void ipSummaryQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : ipSummaryQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import type {LatestReleaseInfo} from '@/lib/services/openflare';
|
||||
|
||||
function formatLogTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '0000-00-00 00:00:00.000';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||
}
|
||||
|
||||
function formatLogLevel(level: string) {
|
||||
return (level || 'info').toUpperCase().padEnd(8, ' ');
|
||||
}
|
||||
|
||||
function getUpgradeStatusBadge(release: LatestReleaseInfo) {
|
||||
if (release.upgrade_status === 'failed') {
|
||||
return { label: '升级失败', variant: 'destructive' as const };
|
||||
}
|
||||
if (release.upgrade_status === 'succeeded') {
|
||||
return { label: '准备重启', variant: 'default' as const };
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return { label: '升级中', variant: 'secondary' as const };
|
||||
}
|
||||
return { label: '空闲', variant: 'outline' as const };
|
||||
}
|
||||
|
||||
export function UpgradeLogPanel({release}: {release: LatestReleaseInfo}) {
|
||||
const statusBadge = getUpgradeStatusBadge(release);
|
||||
const upgradeLogs = release.upgrade_logs ?? [];
|
||||
|
||||
if (upgradeLogs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无升级日志
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border bg-slate-950 px-4 py-3">
|
||||
{upgradeLogs.map((log, index) => (
|
||||
<pre
|
||||
key={`${log.created_at}-${index}`}
|
||||
className="overflow-x-auto border-b border-white/10 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||
>
|
||||
<span className="text-slate-400">{formatLogTimestamp(log.created_at)}</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="text-cyan-300">{formatLogLevel(log.level)}</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="break-all whitespace-pre-wrap text-slate-100">{log.message}</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {ExternalLink, Loader2} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {useOpenFlareServerUpgrade} from '@/lib/hooks/use-openflare-server-upgrade';
|
||||
import type {LatestReleaseInfo, ReleaseChannel} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {formatRelativeTime} from '@/app/(main)/openflare/nodes/components/node-utils';
|
||||
|
||||
import {UpgradeLogPanel} from './upgrade-log-panel';
|
||||
|
||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
return { label: '未检查', variant: 'outline' as const };
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return { label: '升级中', variant: 'secondary' as const };
|
||||
}
|
||||
if (release.has_update) {
|
||||
return { label: '可升级', variant: 'secondary' as const };
|
||||
}
|
||||
return { label: '最新', variant: 'default' as const };
|
||||
}
|
||||
|
||||
export function VersionUpgradeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
canUpgrade = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
canUpgrade?: boolean;
|
||||
}) {
|
||||
const {
|
||||
currentVersion,
|
||||
selectedChannel,
|
||||
release,
|
||||
uploadedBinary,
|
||||
releaseErrorMessage,
|
||||
manualStatusMessage,
|
||||
manualErrorMessage,
|
||||
isInitialLoading,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
uploadProgress,
|
||||
isConfirmingManualUpgrade,
|
||||
handleOpen,
|
||||
handleCheckRelease,
|
||||
handleChannelChange,
|
||||
handleUpgrade,
|
||||
handleUploadBinary,
|
||||
handleConfirmManualUpgrade,
|
||||
} = useOpenFlareServerUpgrade({ open, canUpgrade });
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handleOpen();
|
||||
}
|
||||
}, [open, handleOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedBinary(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const selectedChannelLabel = selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||
const uploadPhaseLabel =
|
||||
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
|
||||
const canConfirmManualUpgrade = Boolean(
|
||||
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||
);
|
||||
|
||||
const isBusy =
|
||||
isChecking || isUpgrading || isUploadingBinary || isConfirmingManualUpgrade;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>服务端版本</DialogTitle>
|
||||
<DialogDescription>
|
||||
默认检查正式版更新;也可检查 preview 发布或手动上传二进制包。升级开始后服务会短暂重启。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">当前版本</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">{currentVersion}</p>
|
||||
<Badge variant={upgradeBadge.variant}>{upgradeBadge.label}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">最新版本</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">{release?.tag_name || '未检查'}</p>
|
||||
{canUpgrade ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
handleChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview')
|
||||
}
|
||||
>
|
||||
{selectedChannelLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{canUpgrade ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
onClick={handleCheckRelease}
|
||||
>
|
||||
{isChecking ? '检查中...' : `检查${selectedChannelLabel}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isInitialLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
加载版本信息...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isInitialLoading && releaseErrorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{releaseErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="online">
|
||||
<TabsList>
|
||||
<TabsTrigger value="online">在线升级</TabsTrigger>
|
||||
{canUpgrade ? <TabsTrigger value="manual">手动升级</TabsTrigger> : null}
|
||||
{release ? <TabsTrigger value="logs">升级日志</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="online" className="space-y-4">
|
||||
{!isInitialLoading && !releaseErrorMessage && !release ? (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未检查{selectedChannelLabel},点击「检查{selectedChannelLabel}」后展示 GitHub Release 信息。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{release ? (
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">
|
||||
GitHub {selectedChannelLabel} · {release.tag_name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '未提供发布时间'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={release.has_update ? 'secondary' : 'default'}>
|
||||
{release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
</Badge>
|
||||
{release.prerelease ? (
|
||||
<Badge variant="secondary">Preview 发布</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">正式发布</Badge>
|
||||
)}
|
||||
{!release.upgrade_supported ? (
|
||||
<Badge variant="destructive">当前平台不支持自动升级</Badge>
|
||||
) : null}
|
||||
{release.in_progress ? (
|
||||
<Badge variant="secondary">升级任务执行中</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{release.body || '暂无更新说明'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{release.html_url ? (
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
查看发布详情
|
||||
<ExternalLink className="size-3 ml-1" />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!release.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{isUpgrading || release.in_progress
|
||||
? '升级中...'
|
||||
: selectedChannel === 'preview'
|
||||
? '升级预览版'
|
||||
: '升级正式版'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{canUpgrade ? (
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">手动升级</CardTitle>
|
||||
<CardDescription>
|
||||
支持上传已编译好的当前平台 Server 可执行文件。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="server-binary">服务端二进制</Label>
|
||||
<Input
|
||||
id="server-binary"
|
||||
type="file"
|
||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||
onChange={(event) => {
|
||||
setSelectedBinary(event.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{canConfirmManualUpgrade ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!canConfirmManualUpgrade ||
|
||||
isConfirmingManualUpgrade ||
|
||||
isUploadingBinary ||
|
||||
isUpgrading
|
||||
}
|
||||
onClick={handleConfirmManualUpgrade}
|
||||
>
|
||||
{isConfirmingManualUpgrade ? '升级中...' : '确认升级'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!selectedBinary ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedBinary) {
|
||||
handleUploadBinary(selectedBinary);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isUploadingBinary
|
||||
? uploadProgress >= 100
|
||||
? '服务端校验中...'
|
||||
: '上传中...'
|
||||
: '上传并检查'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploadingBinary ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{uploadPhaseLabel}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manualErrorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{manualErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!manualErrorMessage && manualStatusMessage ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-600 dark:text-amber-400">
|
||||
{manualStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadedBinary ? (
|
||||
<div className="space-y-4 rounded-lg border border-dashed p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={uploadedBinary.ready_to_upgrade ? 'secondary' : 'outline'}
|
||||
>
|
||||
{uploadedBinary.ready_to_upgrade
|
||||
? '可确认升级'
|
||||
: uploadedBinary.has_update
|
||||
? '待确认'
|
||||
: '不可升级'}
|
||||
</Badge>
|
||||
{!uploadedBinary.upgrade_supported ? (
|
||||
<Badge variant="destructive">当前版本不支持手动升级</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 text-sm">
|
||||
<InfoCell label="文件名" value={uploadedBinary.file_name} />
|
||||
<InfoCell
|
||||
label="上传时间"
|
||||
value={
|
||||
uploadedBinary.uploaded_at
|
||||
? formatDateTime(uploadedBinary.uploaded_at)
|
||||
: '未知'
|
||||
}
|
||||
/>
|
||||
<InfoCell label="当前版本" value={uploadedBinary.current_version} />
|
||||
<InfoCell label="上传版本" value={uploadedBinary.detected_version} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uploadedBinary.comparison_message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未上传升级包
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{release ? (
|
||||
<TabsContent value="logs">
|
||||
<UpgradeLogPanel release={release} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCell({label, value}: {label: string; value: string}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 font-medium break-all">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type {ReleaseChannel};
|
||||
+28
-15
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
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,
|
||||
@@ -16,6 +19,12 @@ 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 个历史快照"),
|
||||
})
|
||||
|
||||
type CleanupFormValues = z.infer<typeof cleanupSchema>
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -29,17 +38,20 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const [keepCount, setKeepCount] = useState(10)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const form = useForm<CleanupFormValues>({
|
||||
resolver: zodResolver(cleanupSchema),
|
||||
defaultValues: { keepCount: 10 },
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (keepCount < 3) {
|
||||
setError("最少保留 3 个历史快照")
|
||||
return
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ keepCount: 10 })
|
||||
}
|
||||
setError(null)
|
||||
onConfirm(keepCount)
|
||||
}
|
||||
}, [form, open])
|
||||
|
||||
const handleConfirm = form.handleSubmit((values) => {
|
||||
onConfirm(values.keepCount)
|
||||
})
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -57,17 +69,18 @@ export function CleanupDialog({
|
||||
id="keepCount"
|
||||
type="number"
|
||||
min={3}
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(Number.parseInt(e.target.value, 10) || 3)}
|
||||
disabled={loading}
|
||||
{...form.register("keepCount", { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">默认为 10 个,最少需保留 3 个。</p>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
{form.formState.errors.keepCount ? (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.keepCount.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
||||
<Button variant="destructive" onClick={() => void handleConfirm()} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
@@ -81,4 +94,4 @@ export function CleanupDialog({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -16,21 +20,51 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
|
||||
|
||||
type FormState = {
|
||||
node_type: NodeType;
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
auto_update_enabled: boolean;
|
||||
geo_manual_override: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude: string;
|
||||
geo_longitude: string;
|
||||
relay_bind_port: string;
|
||||
relay_vhost_http_port: string;
|
||||
};
|
||||
const nodeSchema = z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, '请输入节点名称').max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.string(),
|
||||
relay_bind_port: z.string(),
|
||||
relay_vhost_http_port: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.ip_manual_override && !value.ip.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ip'],
|
||||
message: '锁定 IP 时必须填写节点 IP',
|
||||
});
|
||||
}
|
||||
if (value.node_type === 'tunnel_relay') {
|
||||
const bindPort = Number(value.relay_bind_port);
|
||||
const vhostPort = Number(value.relay_vhost_http_port);
|
||||
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: '请输入有效的绑定端口',
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: '请输入有效的 VHost 端口',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const defaultForm: FormState = {
|
||||
type NodeFormValues = z.infer<typeof nodeSchema>;
|
||||
|
||||
const defaultForm: NodeFormValues = {
|
||||
node_type: 'edge_node',
|
||||
name: '',
|
||||
ip: '',
|
||||
@@ -44,7 +78,7 @@ const defaultForm: FormState = {
|
||||
relay_vhost_http_port: '8080',
|
||||
};
|
||||
|
||||
function buildFormState(node?: NodeItem | null): FormState {
|
||||
function buildFormValues(node?: NodeItem | null): NodeFormValues {
|
||||
if (!node) return defaultForm;
|
||||
|
||||
return {
|
||||
@@ -68,7 +102,7 @@ function buildFormState(node?: NodeItem | null): FormState {
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(form: FormState): NodeMutationPayload {
|
||||
function toPayload(form: NodeFormValues): NodeMutationPayload {
|
||||
const base: NodeMutationPayload = {
|
||||
node_type: form.node_type,
|
||||
name: form.name.trim(),
|
||||
@@ -110,30 +144,24 @@ export function NodeEditorDialog({
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<FormState>(defaultForm);
|
||||
const [error, setError] = useState('');
|
||||
const form = useForm<NodeFormValues>({
|
||||
resolver: zodResolver(nodeSchema),
|
||||
defaultValues: defaultForm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(buildFormState(node));
|
||||
setError('');
|
||||
form.reset(buildFormValues(node));
|
||||
}
|
||||
}, [open, node]);
|
||||
}, [form, open, node]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
setError('请输入节点名称');
|
||||
return;
|
||||
}
|
||||
if (form.ip_manual_override && !form.ip.trim()) {
|
||||
setError('锁定 IP 时必须填写节点 IP');
|
||||
return;
|
||||
}
|
||||
const nodeType = form.watch('node_type');
|
||||
const ipManualOverride = form.watch('ip_manual_override');
|
||||
const geoManualOverride = form.watch('geo_manual_override');
|
||||
|
||||
setError('');
|
||||
await onSubmit(toPayload(form));
|
||||
};
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await onSubmit(toPayload(values));
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
@@ -149,10 +177,10 @@ export function NodeEditorDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>节点类型</Label>
|
||||
<Select
|
||||
value={form.node_type}
|
||||
value={nodeType}
|
||||
disabled={Boolean(node)}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, node_type: value as NodeType }))
|
||||
form.setValue('node_type', value as NodeType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -170,20 +198,28 @@ export function NodeEditorDialog({
|
||||
<Label htmlFor="node-name">节点名称</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="例如 edge-hk-01"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<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>
|
||||
<Input
|
||||
id="node-ip"
|
||||
value={form.ip}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ip: event.target.value }))}
|
||||
placeholder="可选,接入后自动上报"
|
||||
{...form.register('ip')}
|
||||
/>
|
||||
{form.formState.errors.ip ? (
|
||||
<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">
|
||||
@@ -192,9 +228,9 @@ export function NodeEditorDialog({
|
||||
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.ip_manual_override}
|
||||
checked={ipManualOverride}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, ip_manual_override: checked }))
|
||||
form.setValue('ip_manual_override', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -205,34 +241,38 @@ export function NodeEditorDialog({
|
||||
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.auto_update_enabled}
|
||||
checked={form.watch('auto_update_enabled')}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, auto_update_enabled: checked }))
|
||||
form.setValue('auto_update_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.node_type === 'tunnel_relay' ? (
|
||||
{nodeType === 'tunnel_relay' ? (
|
||||
<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"
|
||||
value={form.relay_bind_port}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, relay_bind_port: event.target.value }))
|
||||
}
|
||||
{...form.register('relay_bind_port')}
|
||||
/>
|
||||
{form.formState.errors.relay_bind_port ? (
|
||||
<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>
|
||||
<Input
|
||||
id="relay-vhost-port"
|
||||
value={form.relay_vhost_http_port}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, relay_vhost_http_port: event.target.value }))
|
||||
}
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
/>
|
||||
{form.formState.errors.relay_vhost_http_port ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.relay_vhost_http_port.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -243,50 +283,30 @@ export function NodeEditorDialog({
|
||||
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.geo_manual_override}
|
||||
checked={geoManualOverride}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, geo_manual_override: checked }))
|
||||
form.setValue('geo_manual_override', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.geo_manual_override ? (
|
||||
{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"
|
||||
value={form.geo_name}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input id="geo-name" {...form.register('geo_name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geo-lat">纬度</Label>
|
||||
<Input
|
||||
id="geo-lat"
|
||||
value={form.geo_latitude}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_latitude: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
value={form.geo_longitude}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, geo_longitude: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input id="geo-lng" {...form.register('geo_longitude')} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
@@ -299,4 +319,4 @@ export function NodeEditorDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
|
||||
@@ -23,9 +24,11 @@ export function ProxyRouteDetailPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = Number(searchParams.get('id'));
|
||||
const activeSection = getProxyRouteConfigSection(searchParams.get('section'));
|
||||
const hasValidId = Number.isFinite(routeId) && routeId > 0;
|
||||
|
||||
const [route, setRoute] = useState<ProxyRouteItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [sectionSaving, setSectionSaving] = useState(false);
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
@@ -41,47 +44,36 @@ export function ProxyRouteDetailPageClient() {
|
||||
setRoute(updatedRoute);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
||||
const fetchRoute = useCallback(async () => {
|
||||
if (!hasValidId) {
|
||||
setLoading(false);
|
||||
setRoute(null);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
const fetchRoute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await ProxyRouteService.getById(routeId);
|
||||
if (!cancelled) {
|
||||
setRoute(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setRoute(null);
|
||||
toast.error('规则详情加载失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const data = await ProxyRouteService.getById(routeId);
|
||||
setRoute(data);
|
||||
} catch (error) {
|
||||
setRoute(null);
|
||||
setLoadError(error instanceof Error ? error.message : '未知错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasValidId, routeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoute();
|
||||
}, [fetchRoute]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [routeId]);
|
||||
|
||||
if (!Number.isFinite(routeId) || routeId <= 0) {
|
||||
if (!hasValidId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<p className="text-sm text-muted-foreground">缺少有效的规则 ID。</p>
|
||||
<ErrorInline message="缺少有效的规则 ID。" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,10 +88,24 @@ export function ProxyRouteDetailPageClient() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={loadError}
|
||||
onRetry={() => void fetchRoute()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<p className="text-sm text-muted-foreground">未找到对应规则,请返回列表重试。</p>
|
||||
<EmptyStateWithBorder
|
||||
title="未找到对应规则"
|
||||
description="请返回列表重试。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Search} from 'lucide-react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -19,45 +22,101 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {WAFIPGroup} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
type CountryOption,
|
||||
normalizeItems,
|
||||
type RuleDimension,
|
||||
type RuleListType,
|
||||
type RuleModalState,
|
||||
} from './helpers';
|
||||
import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
|
||||
|
||||
const ruleEntrySchema = z
|
||||
.object({
|
||||
listType: z.enum(['whitelist', 'blacklist']),
|
||||
dimension: z.enum(['ip', 'ip_group', 'country']),
|
||||
ipValue: z.string(),
|
||||
ipGroupIDs: z.array(z.number()),
|
||||
countryValues: z.array(z.string()),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.dimension === 'ip') {
|
||||
if (textToList(value.ipValue).length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ipValue'],
|
||||
message: '请输入至少一个 IP 或 IP 段',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.dimension === 'ip_group') {
|
||||
if (value.ipGroupIDs.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ipGroupIDs'],
|
||||
message: '请选择至少一个 IP 组',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (normalizeItems(value.countryValues).length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['countryValues'],
|
||||
message: '请选择至少一个国家/地区',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type RuleEntryFormValues = z.infer<typeof ruleEntrySchema>;
|
||||
|
||||
const defaultRuleEntryValues: RuleEntryFormValues = {
|
||||
listType: 'blacklist',
|
||||
dimension: 'ip',
|
||||
ipValue: '',
|
||||
ipGroupIDs: [],
|
||||
countryValues: [],
|
||||
};
|
||||
|
||||
interface RuleEntryDialogProps {
|
||||
state: RuleModalState;
|
||||
open: boolean;
|
||||
countryOptions: CountryOption[];
|
||||
ipGroups: WAFIPGroup[];
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<RuleModalState>) => void;
|
||||
onSubmit: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (values: RuleEntryFormValues) => void;
|
||||
}
|
||||
|
||||
export function RuleEntryDialog({
|
||||
state,
|
||||
open,
|
||||
countryOptions,
|
||||
ipGroups,
|
||||
onClose,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RuleEntryDialogProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const form = useForm<RuleEntryFormValues>({
|
||||
resolver: zodResolver(ruleEntrySchema),
|
||||
defaultValues: defaultRuleEntryValues,
|
||||
});
|
||||
|
||||
const listType = form.watch('listType');
|
||||
const dimension = form.watch('dimension');
|
||||
const ipValue = form.watch('ipValue');
|
||||
const ipGroupIDs = form.watch('ipGroupIDs');
|
||||
const countryValues = form.watch('countryValues');
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open) return;
|
||||
if (!open) return;
|
||||
form.reset(defaultRuleEntryValues);
|
||||
setKeyword('');
|
||||
}, [state.dimension, state.open]);
|
||||
}, [form, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword('');
|
||||
}, [dimension, open]);
|
||||
|
||||
const selectedCountrySet = useMemo(
|
||||
() => new Set(state.countryValues),
|
||||
[state.countryValues],
|
||||
() => new Set(countryValues),
|
||||
[countryValues],
|
||||
);
|
||||
const selectedIPGroupSet = useMemo(
|
||||
() => new Set(state.ipGroupIDs),
|
||||
[state.ipGroupIDs],
|
||||
() => new Set(ipGroupIDs),
|
||||
[ipGroupIDs],
|
||||
);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
@@ -73,28 +132,32 @@ export function RuleEntryDialog({
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
const values = selectedCountrySet.has(code)
|
||||
? state.countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...state.countryValues, code]);
|
||||
onChange({ countryValues: values });
|
||||
? countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...countryValues, code]);
|
||||
form.setValue('countryValues', values, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const toggleIPGroup = (id: number) => {
|
||||
const values = selectedIPGroupSet.has(id)
|
||||
? state.ipGroupIDs.filter((item) => item !== id)
|
||||
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
|
||||
onChange({ ipGroupIDs: values });
|
||||
? ipGroupIDs.filter((item) => item !== id)
|
||||
: [...ipGroupIDs, id].sort((left, right) => left - right);
|
||||
form.setValue('ipGroupIDs', values, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
||||
const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
|
||||
const dimensionLabel =
|
||||
state.dimension === 'ip'
|
||||
dimension === 'ip'
|
||||
? 'IP'
|
||||
: state.dimension === 'ip_group'
|
||||
: dimension === 'ip_group'
|
||||
? 'IP 组'
|
||||
: '地域';
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
onSubmit(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={state.open} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加{typeLabel}规则</DialogTitle>
|
||||
@@ -115,9 +178,9 @@ export function RuleEntryDialog({
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={state.listType === option.value ? 'default' : 'outline'}
|
||||
variant={listType === option.value ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
onChange({ listType: option.value as RuleListType })
|
||||
form.setValue('listType', option.value as RuleListType)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -137,9 +200,9 @@ export function RuleEntryDialog({
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={state.dimension === option.value ? 'default' : 'outline'}
|
||||
variant={dimension === option.value ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
onChange({ dimension: option.value as RuleDimension })
|
||||
form.setValue('dimension', option.value as RuleDimension)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
@@ -149,21 +212,28 @@ export function RuleEntryDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.dimension === 'ip' ? (
|
||||
{dimension === 'ip' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>IP / IP 段</Label>
|
||||
<Textarea
|
||||
value={state.ipValue}
|
||||
value={ipValue}
|
||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||
onChange={(event) =>
|
||||
form.setValue('ipValue', event.target.value, { shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
|
||||
</p>
|
||||
{form.formState.errors.ipValue ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.ipValue.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.dimension === 'ip_group' ? (
|
||||
{dimension === 'ip_group' ? (
|
||||
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -173,7 +243,7 @@ export function RuleEntryDialog({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已选 {state.ipGroupIDs.length}
|
||||
已选 {ipGroupIDs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
@@ -208,10 +278,15 @@ export function RuleEntryDialog({
|
||||
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
|
||||
)}
|
||||
</div>
|
||||
{form.formState.errors.ipGroupIDs ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.ipGroupIDs.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.dimension === 'country' ? (
|
||||
{dimension === 'country' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
@@ -226,12 +301,14 @@ export function RuleEntryDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
countryValues: normalizeItems([
|
||||
...state.countryValues,
|
||||
form.setValue(
|
||||
'countryValues',
|
||||
normalizeItems([
|
||||
...countryValues,
|
||||
...filteredCountries.map((option) => option.code),
|
||||
]),
|
||||
})
|
||||
{ shouldValidate: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
全选当前
|
||||
@@ -240,7 +317,9 @@ export function RuleEntryDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ countryValues: [] })}
|
||||
onClick={() =>
|
||||
form.setValue('countryValues', [], { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
@@ -265,15 +344,20 @@ export function RuleEntryDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{form.formState.errors.countryValues ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.countryValues.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={onSubmit}>
|
||||
<Button type="button" onClick={() => void handleSubmit()}>
|
||||
添加
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -26,18 +26,16 @@ import {
|
||||
buildCountryOptions,
|
||||
buildRuleGroupDraft,
|
||||
countRuleEntries,
|
||||
defaultRuleModalState,
|
||||
emptyRuleGroupDraft,
|
||||
formatCountryItem,
|
||||
getListFieldKey,
|
||||
type ListFieldKey,
|
||||
normalizeItems,
|
||||
type RuleModalState,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
import {PowConfigPanel} from './pow-config-panel';
|
||||
import {RuleEntryDialog} from './rule-entry-dialog';
|
||||
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
|
||||
import {RuleListSection} from './rule-list-section';
|
||||
|
||||
const ruleGroupSchema = z.object({
|
||||
@@ -69,7 +67,7 @@ export function RuleGroupDialog({
|
||||
onSubmit,
|
||||
}: RuleGroupDialogProps) {
|
||||
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
|
||||
const [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState);
|
||||
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
|
||||
|
||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||
const countryLabelMap = useMemo(
|
||||
@@ -101,7 +99,7 @@ export function RuleGroupDialog({
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleModal(defaultRuleModalState);
|
||||
setRuleEntryOpen(false);
|
||||
}, [form, group, open]);
|
||||
|
||||
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
|
||||
@@ -126,23 +124,21 @@ export function RuleGroupDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const applyRuleModal = () => {
|
||||
const applyRuleEntry = (entry: RuleEntryFormValues) => {
|
||||
const values =
|
||||
ruleModal.dimension === 'ip'
|
||||
? textToList(ruleModal.ipValue)
|
||||
: ruleModal.dimension === 'ip_group'
|
||||
? ruleModal.ipGroupIDs.map(String)
|
||||
: normalizeItems(ruleModal.countryValues);
|
||||
entry.dimension === 'ip'
|
||||
? textToList(entry.ipValue)
|
||||
: entry.dimension === 'ip_group'
|
||||
? entry.ipGroupIDs.map(String)
|
||||
: normalizeItems(entry.countryValues);
|
||||
|
||||
if (values.length === 0) return;
|
||||
|
||||
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
|
||||
const listKey = getListFieldKey(entry.listType, entry.dimension);
|
||||
setListsDraft((current) =>
|
||||
updateDraftList(current, listKey, (items) =>
|
||||
normalizeItems([...items, ...values]),
|
||||
),
|
||||
);
|
||||
setRuleModal(defaultRuleModalState);
|
||||
setRuleEntryOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
@@ -244,7 +240,7 @@ export function RuleGroupDialog({
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRuleModal({ ...defaultRuleModalState, open: true })
|
||||
setRuleEntryOpen(true)
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
@@ -392,12 +388,11 @@ export function RuleGroupDialog({
|
||||
</Dialog>
|
||||
|
||||
<RuleEntryDialog
|
||||
state={ruleModal}
|
||||
open={ruleEntryOpen}
|
||||
countryOptions={countryOptions}
|
||||
ipGroups={ipGroups}
|
||||
onClose={() => setRuleModal(defaultRuleModalState)}
|
||||
onChange={(patch) => setRuleModal((current) => ({ ...current, ...patch, open: true }))}
|
||||
onSubmit={applyRuleModal}
|
||||
onOpenChange={setRuleEntryOpen}
|
||||
onSubmit={applyRuleEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+25
-21
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
@@ -23,11 +25,13 @@ import {getErrorMessage} from './website-utils';
|
||||
|
||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
||||
|
||||
type DnsAccountFormValues = {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
};
|
||||
const dnsAccountSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入名称').max(255),
|
||||
type: z.string().min(1),
|
||||
authorization: z.string().trim().min(1, '请输入 Token'),
|
||||
});
|
||||
|
||||
type DnsAccountFormValues = z.infer<typeof dnsAccountSchema>;
|
||||
|
||||
interface DnsAccountCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -42,16 +46,16 @@ export function DnsAccountCreateDialog({
|
||||
}: DnsAccountCreateDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const {register, handleSubmit, setValue, watch, formState, reset} =
|
||||
useForm<DnsAccountFormValues>({
|
||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||
});
|
||||
const form = useForm<DnsAccountFormValues>({
|
||||
resolver: zodResolver(dnsAccountSchema),
|
||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: DnsAccountService.create,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
reset();
|
||||
form.reset();
|
||||
setError('');
|
||||
onCreated?.();
|
||||
onOpenChange(false);
|
||||
@@ -59,7 +63,7 @@ export function DnsAccountCreateDialog({
|
||||
onError: (err) => setError(getErrorMessage(err)),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
setError('');
|
||||
let auth = values.authorization.trim();
|
||||
if (!auth.startsWith('{')) {
|
||||
@@ -69,7 +73,7 @@ export function DnsAccountCreateDialog({
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
form.reset();
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -91,18 +95,18 @@ export function DnsAccountCreateDialog({
|
||||
<Label>账号名称</Label>
|
||||
<Input
|
||||
placeholder="Cloudflare 邮箱账号"
|
||||
{...register('name', {required: '请输入名称'})}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">{formState.errors.name.message}</p>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>DNS 服务商</Label>
|
||||
<Select
|
||||
value={watch('type')}
|
||||
onValueChange={(value) => setValue('type', value)}
|
||||
value={form.watch('type')}
|
||||
onValueChange={(value) => form.setValue('type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -116,12 +120,12 @@ export function DnsAccountCreateDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>API Token</Label>
|
||||
<Input
|
||||
{...register('authorization', {required: '请输入 Token'})}
|
||||
{...form.register('authorization')}
|
||||
placeholder="请勿使用 Global API Key"
|
||||
/>
|
||||
{formState.errors.authorization ? (
|
||||
{form.formState.errors.authorization ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{formState.errors.authorization.message}
|
||||
{form.formState.errors.authorization.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -145,4 +149,4 @@ export function DnsAccountCreateDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user