migrate frontend

This commit is contained in:
ryan
2026-06-18 18:05:00 +08:00
parent 31b114b0cc
commit e7b8993181
27 changed files with 1836 additions and 383 deletions
@@ -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};
@@ -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}
/>
</>
);
@@ -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>
);
}
}
@@ -10,6 +10,7 @@ import {useNotificationSettings} from "@/contexts/notification-settings-context"
import {SidebarTrigger} from "@/components/ui/sidebar"
import {useTheme} from "next-themes"
import {useRouter} from "next/navigation"
import {OpenFlareVersionEntry} from "@/components/layout/openflare-version-entry"
import {SearchDialog} from "@/components/layout/search-dialog"
import {Kbd} from "@/components/ui/kbd"
@@ -100,6 +101,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
</span>
</div>
<div className="flex items-center gap-1">
<OpenFlareVersionEntry />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
<Search className="size-[18px]" />
<span className="sr-only">搜索</span>
@@ -125,6 +127,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
</div>
<div className="ml-auto flex items-center gap-1">
<OpenFlareVersionEntry />
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
@@ -0,0 +1,83 @@
'use client';
import {useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {usePathname} from 'next/navigation';
import {VersionUpgradeDialog} from '@/app/(main)/openflare/components/version-upgrade-dialog';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {useUser} from '@/contexts/user-context';
import {
openflareLatestReleaseQueryKey,
openflarePublicStatusQueryKey,
} from '@/lib/hooks/use-openflare-server-upgrade';
import {StatusService, UpdateService} from '@/lib/services/openflare';
export function OpenFlareVersionEntry() {
const pathname = usePathname();
const {user} = useUser();
const [dialogOpen, setDialogOpen] = useState(false);
const isOpenFlareRoute = pathname.startsWith('/openflare');
const canUpgrade = Boolean(user?.is_admin);
const statusQuery = useQuery({
queryKey: openflarePublicStatusQueryKey,
queryFn: () => StatusService.getPublicStatus(),
enabled: isOpenFlareRoute,
staleTime: 60_000,
});
const releaseQuery = useQuery({
queryKey: openflareLatestReleaseQueryKey('stable'),
queryFn: () => UpdateService.getLatestRelease('stable'),
enabled: isOpenFlareRoute && canUpgrade,
staleTime: 60 * 60 * 1000,
});
if (!isOpenFlareRoute) {
return null;
}
const version = statusQuery.data?.version || releaseQuery.data?.current_version || 'unknown';
const hasUpdate = Boolean(canUpgrade && releaseQuery.data?.has_update);
const versionLabel = hasUpdate ? `OpenFlare ${version} · 可升级` : `OpenFlare ${version}`;
return (
<>
<Button
type="button"
variant="outline"
size="sm"
className="hidden h-8 gap-2 px-2.5 text-xs md:inline-flex"
onClick={() => setDialogOpen(true)}
>
<span className="max-w-[180px] truncate">{versionLabel}</span>
{hasUpdate ? <Badge variant="secondary" className="h-5 px-1.5 text-[10px]">新</Badge> : null}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 md:hidden"
onClick={() => setDialogOpen(true)}
aria-label="服务端版本"
>
<Badge
variant={hasUpdate ? 'secondary' : 'outline'}
className="h-6 px-2 text-[10px] font-medium"
>
版本
</Badge>
</Button>
<VersionUpgradeDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
canUpgrade={canUpgrade}
/>
</>
);
}
@@ -0,0 +1,348 @@
'use client';
import {useMutation, useQuery} from '@tanstack/react-query';
import {useCallback, useEffect, useRef, useState} from 'react';
import type {
LatestReleaseInfo,
ReleaseChannel,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
} from '@/lib/services/openflare';
import {StatusService, UpdateService} from '@/lib/services/openflare';
const UPGRADE_STATUS_POLL_INTERVAL = 3000;
export const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
export const openflareLatestReleaseQueryKey = (channel: ReleaseChannel = 'stable') =>
['openflare', 'latest-release', channel] as const;
export function mergeReleaseWithUpgradeStream(
release: LatestReleaseInfo | null | undefined,
stream: UpgradeStreamSnapshot | null,
) {
if (!release || !stream) {
return release;
}
return {
...release,
in_progress: stream.in_progress,
upgrade_status: stream.upgrade_status,
upgrade_logs: stream.upgrade_logs,
};
}
export function useOpenFlareServerUpgrade({
open,
canUpgrade,
}: {
open: boolean;
canUpgrade: boolean;
}) {
const [selectedChannel, setSelectedChannel] = useState<ReleaseChannel>('stable');
const [feedback, setFeedback] = useState<string | null>(null);
const [manualStatusMessage, setManualStatusMessage] = useState<string | null>(null);
const [manualErrorMessage, setManualErrorMessage] = useState<string | null>(null);
const [uploadedBinary, setUploadedBinary] = useState<UploadedServerBinaryInfo | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [upgradeStream, setUpgradeStream] = useState<UpgradeStreamSnapshot | null>(null);
const upgradeRefreshPendingRef = useRef(false);
const upgradeReloadStartedRef = useRef(false);
const upgradeReloadTimerRef = useRef<number | null>(null);
const statusQuery = useQuery({
queryKey: openflarePublicStatusQueryKey,
queryFn: () => StatusService.getPublicStatus(),
enabled: open,
});
const stableReleaseQuery = useQuery({
queryKey: openflareLatestReleaseQueryKey('stable'),
queryFn: () => UpdateService.getLatestRelease('stable'),
enabled: open && canUpgrade,
refetchInterval: (query) => {
const release = query.state.data;
if (open && release?.in_progress) {
return UPGRADE_STATUS_POLL_INTERVAL;
}
return 60 * 60 * 1000;
},
});
const previewReleaseQuery = useQuery({
queryKey: openflareLatestReleaseQueryKey('preview'),
queryFn: () => UpdateService.getLatestRelease('preview'),
enabled: false,
refetchInterval: (query) => {
const release = query.state.data;
if (open && release?.in_progress) {
return UPGRADE_STATUS_POLL_INTERVAL;
}
return false;
},
});
const scheduleUpgradePageReload = useCallback(() => {
if (upgradeReloadStartedRef.current) {
return;
}
upgradeReloadStartedRef.current = true;
setFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
const reloadWhenServerReady = async () => {
try {
await StatusService.getPublicStatus();
window.location.reload();
} catch {
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1500);
}
};
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1200);
}, []);
useEffect(() => {
return () => {
if (upgradeReloadTimerRef.current !== null) {
window.clearTimeout(upgradeReloadTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!open || !canUpgrade) {
setUpgradeStream(null);
return;
}
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const connect = () => {
if (closed) {
return;
}
socket = UpdateService.createUpgradeLogsWebSocket();
if (!socket) {
return;
}
socket.onmessage = (event) => {
const snapshot = UpdateService.parseUpgradeStreamSnapshot(String(event.data));
if (snapshot) {
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
upgradeRefreshPendingRef.current = true;
}
if (snapshot.upgrade_status === 'failed') {
upgradeRefreshPendingRef.current = false;
}
setUpgradeStream(snapshot);
}
};
socket.onclose = () => {
if (closed) {
return;
}
if (upgradeRefreshPendingRef.current) {
scheduleUpgradePageReload();
return;
}
reconnectTimer = window.setTimeout(connect, 1500);
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
socket?.close();
};
}, [canUpgrade, open, scheduleUpgradePageReload]);
const upgradeMutation = useMutation({
mutationFn: (channel: ReleaseChannel) => UpdateService.upgradeServer(channel),
onSuccess: (release) => {
upgradeRefreshPendingRef.current = true;
setUploadedBinary(null);
setManualStatusMessage(null);
setManualErrorMessage(null);
setFeedback(
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
);
void stableReleaseQuery.refetch();
if (release.channel === 'preview') {
void previewReleaseQuery.refetch();
}
},
onError: (error) => {
setFeedback(error instanceof Error ? error.message : '升级失败,请稍后重试。');
},
});
const uploadBinaryMutation = useMutation({
mutationFn: (binary: File) =>
UpdateService.uploadServerBinary(binary, (progress) => {
setUploadProgress(progress);
}),
onSuccess: (candidate) => {
setUploadProgress(0);
setFeedback(null);
setManualErrorMessage(null);
setUploadedBinary(candidate);
setManualStatusMessage(candidate.comparison_message);
},
onError: (error) => {
setUploadProgress(0);
setUploadedBinary(null);
setManualStatusMessage(null);
setManualErrorMessage(
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
);
},
});
const confirmManualUpgradeMutation = useMutation({
mutationFn: (uploadToken: string) => UpdateService.confirmManualServerUpgrade(uploadToken),
onSuccess: (candidate) => {
upgradeRefreshPendingRef.current = true;
setFeedback(null);
setManualErrorMessage(null);
setUploadedBinary(candidate);
setManualStatusMessage(
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
);
void stableReleaseQuery.refetch();
void previewReleaseQuery.refetch();
},
onError: (error) => {
setManualStatusMessage(null);
setManualErrorMessage(
error instanceof Error ? error.message : '确认手动升级失败,请稍后重试。',
);
},
});
const resetTransientState = useCallback(() => {
setSelectedChannel('stable');
setFeedback(null);
setManualStatusMessage(null);
setManualErrorMessage(null);
setUploadedBinary(null);
setUploadProgress(0);
setUpgradeStream(null);
upgradeRefreshPendingRef.current = false;
upgradeReloadStartedRef.current = false;
}, []);
const handleOpen = useCallback(() => {
resetTransientState();
if (canUpgrade) {
void stableReleaseQuery.refetch();
}
}, [canUpgrade, resetTransientState, stableReleaseQuery]);
const handleCheckRelease = useCallback(() => {
setFeedback(null);
if (!canUpgrade) {
return;
}
if (selectedChannel === 'preview') {
void previewReleaseQuery.refetch();
return;
}
void stableReleaseQuery.refetch();
}, [canUpgrade, previewReleaseQuery, selectedChannel, stableReleaseQuery]);
const handleChannelChange = useCallback((channel: ReleaseChannel) => {
setSelectedChannel(channel);
setFeedback(null);
}, []);
const handleUpgrade = useCallback(() => {
setFeedback(null);
setManualStatusMessage(null);
setManualErrorMessage(null);
upgradeMutation.mutate(selectedChannel);
}, [selectedChannel, upgradeMutation]);
const handleUploadBinary = useCallback(
(binary: File) => {
setUploadProgress(0);
setManualStatusMessage(null);
setManualErrorMessage(null);
uploadBinaryMutation.mutate(binary);
},
[uploadBinaryMutation],
);
const handleConfirmManualUpgrade = useCallback(() => {
if (!uploadedBinary?.upload_token) {
setManualStatusMessage(null);
setManualErrorMessage('请先上传并检查升级包。');
return;
}
setFeedback(null);
setManualStatusMessage(null);
setManualErrorMessage(null);
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
}, [confirmManualUpgradeMutation, uploadedBinary?.upload_token]);
const selectedRelease =
selectedChannel === 'preview' ? previewReleaseQuery.data : stableReleaseQuery.data;
const releaseWithStream = mergeReleaseWithUpgradeStream(selectedRelease, upgradeStream);
const selectedReleaseError =
selectedChannel === 'preview' ? previewReleaseQuery.error : stableReleaseQuery.error;
const isSelectedReleaseError =
selectedChannel === 'preview' ? previewReleaseQuery.isError : stableReleaseQuery.isError;
const isChecking =
selectedChannel === 'preview'
? previewReleaseQuery.isFetching
: stableReleaseQuery.isFetching;
const isInitialLoading =
(selectedChannel === 'preview'
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) && canUpgrade;
const releaseErrorMessage =
feedback ||
(isSelectedReleaseError
? selectedReleaseError instanceof Error
? selectedReleaseError.message
: '版本检查失败,请稍后重试。'
: undefined);
const currentVersion = statusQuery.data?.version || releaseWithStream?.current_version || 'unknown';
return {
currentVersion,
selectedChannel,
release: releaseWithStream,
uploadedBinary,
releaseErrorMessage,
manualStatusMessage,
manualErrorMessage,
isInitialLoading,
isChecking,
isUpgrading: upgradeMutation.isPending,
isUploadingBinary: uploadBinaryMutation.isPending,
uploadProgress,
isConfirmingManualUpgrade: confirmManualUpgradeMutation.isPending,
stableRelease: stableReleaseQuery.data,
handleOpen,
handleCheckRelease,
handleChannelChange,
handleUpgrade,
handleUploadBinary,
handleConfirmManualUpgrade,
};
}
@@ -5,6 +5,7 @@ import {
Gauge,
GitBranch,
Globe,
Info,
LayoutDashboard,
MapPin,
Route,
@@ -43,6 +44,7 @@ export const openflareNavItems: OpenFlareNavItem[] = [
{title: '访问日志', url: '/openflare/access-logs', icon: ScrollText},
{title: '应用日志', url: '/openflare/apply-logs', icon: ClipboardList},
{title: '性能', url: '/openflare/performance', icon: Gauge},
{title: '关于', url: '/openflare/about', icon: Info},
];
/** 网站模块页内二级导航 */
+3
View File
@@ -27,6 +27,7 @@ import {PushService} from './push';
import {AdminUploadService, UploadService} from './upload';
import {UserService} from './user';
import {
AboutService,
AccessLogService,
ApplyLogService,
ConfigVersionService,
@@ -61,6 +62,7 @@ const services = {
adminUpload: AdminUploadService,
dbManage: DbManageService,
push: PushService,
openflareAbout: AboutService,
openflareNode: NodeService,
openflareProxyRoute: ProxyRouteService,
openflareConfigVersion: ConfigVersionService,
@@ -181,6 +183,7 @@ export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesReques
// ==================== OpenFlare 业务服务导出 ====================
export {
AboutService,
LegacyOpenFlareBaseService,
NodeService,
ProxyRouteService,
@@ -0,0 +1,9 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
export class AboutService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api';
static getAboutContent(): Promise<string> {
return this.legacyGet<string>('/about');
}
}
@@ -1,3 +1,4 @@
export { AboutService } from './about.service';
export { LegacyOpenFlareBaseService } from './legacy-base.service';
export type { LegacyApiResponse } from './legacy-base.service';
@@ -87,6 +88,9 @@ export type {
DatabaseCleanupTarget,
LatestReleaseInfo,
OpenFlarePublicStatus,
UpgradeLogItem,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
OriginDetail,
OriginItem,
OriginMutationPayload,
@@ -108,6 +112,7 @@ export type {
TlsCertificateMutationPayload,
} from './types';
import {AboutService} from './about.service';
import {AccessLogService} from './access-log.service';
import {ApplyLogService} from './apply-log.service';
import {ConfigVersionService} from './config-version.service';
@@ -126,6 +131,7 @@ import {WafService} from './waf.service';
import {WebsiteService} from './website.service';
export const openflareServices = {
about: AboutService,
node: NodeService,
proxyRoute: ProxyRouteService,
configVersion: ConfigVersionService,
@@ -669,10 +669,28 @@ export interface LatestReleaseInfo {
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
upgrade_status: string;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UpgradeStreamSnapshot {
in_progress: boolean;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UploadedServerBinaryInfo {
upload_token: string;
file_name: string;
detected_version: string;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
ready_to_upgrade: boolean;
comparison_message: string;
uploaded_at: string;
}
export interface WAFRuleGroup {
id: number;
name: string;
@@ -1,16 +1,135 @@
import type {InternalAxiosRequestConfig} from 'axios';
import {getApiBaseUrl} from '@/lib/services/core/config';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {LegacyApiResponse} from './legacy-base.service';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {LatestReleaseInfo, ReleaseChannel} from './types';
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo,} from './types';
/**
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
*
* 可选集成:在顶栏或 Admin 升级入口调用 `getLatestRelease()` 展示版本与更新状态;
* 完整升级流程(upgrade / manual-upload / logs WebSocket)可对接 Wavelet `admin/updater`。
*/
export class UpdateService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/update';
private static getLegacyApiUrl(path: string): string {
const baseURL = getApiBaseUrl();
const fullPath = this.getFullPath(path);
if (!baseURL) {
return fullPath;
}
return `${baseURL.replace(/\/$/, '')}${fullPath}`;
}
static getLatestRelease(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.legacyGet<LatestReleaseInfo>('/latest-release', { channel });
}
static upgradeServer(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.legacyPost<LatestReleaseInfo>('/upgrade', { channel });
}
static uploadServerBinary(
binary: File,
onProgress?: (progress: number) => void,
): Promise<UploadedServerBinaryInfo> {
const formData = new FormData();
formData.append('binary', binary);
if (!onProgress) {
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
} as InternalAxiosRequestConfig);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.getLegacyApiUrl('/manual-upload'));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
xhr.addEventListener('load', () => {
let payload: LegacyApiResponse<UploadedServerBinaryInfo> | null = null;
try {
payload = JSON.parse(
xhr.responseText,
) as LegacyApiResponse<UploadedServerBinaryInfo>;
} catch {
payload = null;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(
new ApiErrorBase(
payload?.message || `请求失败(${xhr.status})`,
undefined,
xhr.status,
),
);
return;
}
if (!payload) {
reject(new ApiErrorBase('响应格式无效', undefined, xhr.status));
return;
}
if (!payload.success) {
reject(new ApiErrorBase(payload.message || '请求失败', undefined, xhr.status));
return;
}
resolve(payload.data);
});
xhr.addEventListener('error', () => {
reject(new ApiErrorBase('上传过程中网络连接中断,请检查网络后重试', undefined, 0));
});
xhr.send(formData);
});
}
static confirmManualServerUpgrade(uploadToken: string): Promise<UploadedServerBinaryInfo> {
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upgrade', {
upload_token: uploadToken,
});
}
static createUpgradeLogsWebSocket(): WebSocket | null {
if (typeof window === 'undefined') {
return null;
}
const apiUrl = this.getLegacyApiUrl('/logs/ws');
const resolvedUrl = apiUrl.startsWith('http://')
? `ws://${apiUrl.slice('http://'.length)}`
: apiUrl.startsWith('https://')
? `wss://${apiUrl.slice('https://'.length)}`
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${apiUrl}`;
return new WebSocket(resolvedUrl);
}
static parseUpgradeStreamSnapshot(rawMessage: string): UpgradeStreamSnapshot | null {
try {
const parsed = JSON.parse(rawMessage) as Partial<UpgradeStreamSnapshot>;
if (
typeof parsed.in_progress !== 'boolean' ||
typeof parsed.upgrade_status !== 'string' ||
!Array.isArray(parsed.upgrade_logs)
) {
return null;
}
return {
in_progress: parsed.in_progress,
upgrade_status: parsed.upgrade_status,
upgrade_logs: parsed.upgrade_logs,
};
} catch {
return null;
}
}
}
+2
View File
@@ -41,6 +41,8 @@ sidebar: false
- 将 OpenFlare 管理控制台从 `openflare-server/web` 迁移至 `Wavelet/frontend/app/(main)/openflare/`,复用 Wavelet shadcn 组件与 Session 鉴权;新增 `LegacyOpenFlareBaseService` 对接 `/api/*` 业务 API。
- 实装节点、代理规则(6 Section)、配置发布、WAF、网站/证书/DNS、Pages、源站、访问/应用日志、仪表盘、性能调优等业务页面;Admin 设置新增 OpenFlare 运维扩展 Tab。
- 修复静态导出构建中 `useSearchParams` 未包裹 Suspense 的问题;`pnpm build:embed` 全量 46 页通过。
- 补全前端迁移收尾:新增 `/openflare/about` 关于页与 `AboutService`;补全 `UpdateService` 升级流程(在线升级、手动上传、WebSocket 日志流)及顶栏版本升级入口。
- 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。
## [v2.3.4] - 2026-06-17
@@ -2,7 +2,7 @@
> **文档类型**:实现计划(Implementation Plan)
> **创建日期**:2026-06-18
> **状态**:基本完成(FC-1~19 已实装;验证见 `docs/plan/verify-frontend-*.md`)
> **状态**:✅ 迁移完成(FC-1~20 已实装;验证见 `docs/plan/verify-frontend-*.md`)
> **前置阅读**:[`Wavelet/AGENTS.md`](../../Wavelet/AGENTS.md)、[`docs/plan/20260618-openflare-wavelet-backend-migration.md`](./20260618-openflare-wavelet-backend-migration.md)
> **依赖**:后端阶段一(旧路径 `/api/*` 兼容)完成后启动;阶段二可与后端阶段五并行
@@ -1,6 +1,6 @@
# OpenFlare 前端迁移 — 任务拆分与 AI 委派
> **状态**:基本完成(验证通过,少量 P1 打磨待办)
> **状态**:✅ 迁移完成(多角度验证通过;P2 可选打磨待办)
> **目标前端**:`Wavelet/frontend/`(Next.js 16 + shadcn/ui + Session 鉴权)
> **验证报告**:[`verify-frontend-routes.md`](./verify-frontend-routes.md)、[`verify-frontend-services.md`](./verify-frontend-services.md)、[`verify-frontend-ui.md`](./verify-frontend-ui.md)、[`verify-frontend-build.md`](./verify-frontend-build.md)
@@ -12,7 +12,7 @@
| F-NODE | 节点(含 Relay/Tunnel 详情) | ✅ |
| F-PROXY | 代理规则(含 6 Section 实装) | ✅ |
| F-CFG | 配置发布 + 应用日志 | ✅ |
| F-DASH | 总览仪表盘 | ✅(缺世界地图) |
| F-DASH | 总览仪表盘 | ✅(缺世界地图,P2) |
| F-WAF | WAF 规则组 + IP 组 | ✅ |
| F-WEB | 网站/证书/DNS | ✅ |
| F-PAGES | Pages 托管 | ✅ |
@@ -21,6 +21,8 @@
| F-PERF | 性能调优 | ✅ |
| F-ADMIN | Admin 运维设置扩展 | ✅ |
| F-AUTH | Wavelet 登录/用户复用 | ✅(原生,未改登录页) |
| F-ABOUT | About 页(FC-20) | ✅ |
| F-UPDATE | 服务升级 UI + UpdateService | ✅ |
## 验证结果摘要
@@ -28,16 +30,17 @@
|---|---|
| `tsc --noEmit` | ✅ |
| `pnpm lint` | ✅ |
| `pnpm build:embed` | ✅(46 静态页;已修 Suspense) |
| 路由覆盖 FC-1~19 | ✅ 18 完整 + 1 部分 |
| Service 覆盖 | ⚠️ `update` 升级流程待补 |
| `pnpm build:embed` | ✅(47 静态页) |
| 路由覆盖 FC-1~20 | ✅ 20/20 文件(FC-1 世界地图 P2) |
| Service 覆盖 | ✅ 100% |
| UI 规范 | ✅ PASS |
## 剩余 P1 待办
## P2 可选待办
1. **FC-20** About 页或链到 `/docs`
2. **UpdateService** 补全 upgrade/manual-upload/WS 日志
3. **UI 打磨**:部分 Dialog 补 Zod;access-logs/proxy-detail error 态
4. **FC-1** 世界地图(可选,当前用 geo 列表)
1. **FC-1** 世界地图(当前用 geo 列表)
2. **UI 打磨** — proxy 列表 error 态、access-logs cleanup Zod、node-observability error
3. **废弃旧前端** — 移除 `openflare-server/web` 引用(阶段五)
4. **E2E** — Playwright 核心路径
## 验收命令
+70 -83
View File
@@ -10,17 +10,16 @@
|------|---------|-----------|--------|
| TypeScript | `pnpm exec tsc --noEmit` | **0** | ✅ Pass |
| Lint | `pnpm lint` | **0** | ✅ Pass |
| Production build | `pnpm build:embed` | **1** | ❌ Fail |
| Standard build | `pnpm build` | **1** | ❌ Fail (same error) |
| Production build | `pnpm build:embed` | **0** | ✅ Pass |
**Overall:** TypeScript and lint are clean. Both build targets fail during static page generation due to a missing React `Suspense` boundary around `useSearchParams()`.
**Overall:** All three gates pass. Static export completed with **47/47** pages generated.
---
## 1. TypeScript (`pnpm exec tsc --noEmit`)
- **Exit code:** `0`
- **Duration:** ~6.2s
- **Duration:** ~16.2s
- **Errors:** None
- **Warnings:** None
@@ -29,7 +28,7 @@
## 2. ESLint (`pnpm lint`)
- **Exit code:** `0`
- **Duration:** ~5.6s
- **Duration:** ~24.4s
- **Command:** `eslint` (no extra args)
- **Errors:** None
- **Warnings:** None
@@ -40,112 +39,100 @@
**Production target:** `build:embed` — sets `NEXT_STANDALONE_EXPORT=true`, which enables `output: 'export'` in `next.config.ts` for static export embedded in the Go backend.
- **Exit code:** `1`
- **Duration:** ~57s (compile ~29.5s, TypeScript check ~23.9s)
- **Exit code:** `0`
- **Duration:** ~72s (compile ~44s, TypeScript check ~20.1s, static generation ~2.2s)
### Build progress
- ✅ Compiled successfully
- ✅ TypeScript check passed during build
- ❌ Static page generation failed at **0/46** pages
- ✅ Static page generation completed at **47/47** pages
### Error
### Warnings
```
⨯ useSearchParams() should be wrapped in a suspense boundary at page "/openflare/nodes".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
Error occurred prerendering page "/openflare/nodes".
Export encountered an error on /(main)/openflare/nodes/page: /openflare/nodes, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
⚠ Statically exporting a Next.js application via `next export` disables API routes and middleware.
This command is meant for static-only hosts, and is not necessary to make your application static.
Pages in your application without server-side data dependencies will be automatically statically exported by `next build`, including pages powered by `getStaticProps`.
Learn more: https://nextjs.org/docs/messages/api-routes-static-export
```
### Affected files
### Static pages (47 routes)
| File | Issue |
|------|-------|
| `app/(main)/openflare/nodes/page.tsx` | `useSearchParams()` at line 36 (page default export) |
| `app/(main)/openflare/nodes/components/node-type-filter.tsx` | `useSearchParams()` at line 57 (rendered inside nodes page) |
| Route |
|-------|
| `/` |
| `/_not-found` |
| `/admin/database` |
| `/admin/demo` |
| `/admin/files` |
| `/admin/logs` |
| `/admin/push` |
| `/admin/settings` |
| `/admin/system` |
| `/admin/tasks` |
| `/admin/users` |
| `/docs/api` |
| `/docs/how-to-use` |
| `/docs/privacy-policy` |
| `/docs/terms-of-service` |
| `/files` |
| `/home` |
| `/icon` |
| `/login` |
| `/openflare` |
| `/openflare/about` |
| `/openflare/access-logs` |
| `/openflare/apply-logs` |
| `/openflare/config-versions` |
| `/openflare/nodes` |
| `/openflare/nodes/detail` |
| `/openflare/origins` |
| `/openflare/origins/detail` |
| `/openflare/pages` |
| `/openflare/pages/detail` |
| `/openflare/performance` |
| `/openflare/proxy-routes` |
| `/openflare/proxy-routes/detail` |
| `/openflare/waf` |
| `/openflare/waf/ip-groups` |
| `/openflare/websites` |
| `/openflare/websites/certificates` |
| `/openflare/websites/detail` |
| `/openflare/websites/dns-accounts` |
| `/register` |
| `/settings` |
| `/settings/access-token` |
| `/settings/appearance` |
| `/settings/notifications` |
| `/settings/profile` |
| `/settings/security` |
### Build output size
Not available — build aborted before artifact generation completed.
All routes prerendered as static content (`○`). Middleware proxy is present but not exported in static mode (expected).
---
## 4. Standard Build (`pnpm build`)
## Notes
Ran for comparison (uses rewrites instead of static export).
- **Exit code:** `1`
- **Duration:** ~65s
- **Error:** Identical `useSearchParams()` / missing `Suspense` failure on `/openflare/nodes`
- Initial `pnpm build:embed` attempt failed with "Another next build process is already running" due to a stale `.next/lock` from a concurrent build. Lock cleared and build re-run successfully.
- Previous verification on this date documented a `useSearchParams()` / missing `Suspense` failure on `/openflare/nodes`. That issue is no longer present in the current tree.
---
## Root Cause
## CI Gate (recommended)
Next.js 16 requires `useSearchParams()` to be used inside a `<Suspense>` boundary when pages are statically prerendered/exported. The nodes page is a client component that calls `useSearchParams()` directly in both the page and a child component (`NodeTypeFilter`).
Other pages in the codebase already follow the correct pattern:
- `app/(auth)/login/page.tsx` — wraps content in `<Suspense>`
- `app/(auth)/register/page.tsx` — wraps content in `<Suspense>`
- `app/(main)/admin/tasks/page.tsx` — wraps content in `<Suspense fallback={...}>`
- `app/(main)/openflare/proxy-routes/detail/page.tsx` — splits into `page.tsx` + `page-client.tsx` with `<Suspense>`
---
## Other `useSearchParams()` Usages (likely to fail after nodes fix)
These files also use `useSearchParams()` without a visible `Suspense` wrapper at the page level. They may fail once `/openflare/nodes` is fixed and generation continues:
| File |
|------|
| `app/(main)/openflare/nodes/detail/page.tsx` |
| `app/(main)/openflare/websites/detail/page.tsx` |
| `app/(main)/openflare/origins/detail/page.tsx` |
| `app/(main)/openflare/apply-logs/page.tsx` |
| `app/(main)/openflare/pages/detail/page.tsx` |
| `components/auth/login-form.tsx` |
| `components/auth/login-page.tsx` |
| `components/auth/register-form.tsx` |
(Auth pages are likely safe because `login/page.tsx` and `register/page.tsx` already wrap them in `<Suspense>`.)
---
## Recommendations
### Priority 1 — Fix `/openflare/nodes` build blocker
Refactor using the existing `proxy-routes/detail` pattern:
1. Create `app/(main)/openflare/nodes/page-client.tsx` with the current page logic.
2. Change `page.tsx` to a server component that wraps the client component in `<Suspense fallback={<LoadingState />}>`.
Alternatively, wrap `<NodeTypeFilter />` and the `useSearchParams()` usage in a single child component inside `<Suspense>`.
### Priority 2 — Audit remaining pages
Apply the same `Suspense` pattern to all detail/list pages using `useSearchParams()` (see table above) to avoid repeated build failures at 1/46, 2/46, etc.
### Priority 3 — Add a CI gate
Run all three checks in CI before merge:
Run all three checks before merge:
```bash
cd Wavelet/frontend
pnpm exec tsc --noEmit
pnpm lint
pnpm build:embed
```
### Priority 4 — Optional lint rule
Consider an ESLint rule or codemod to flag `useSearchParams()` usage outside `Suspense` boundaries, since `tsc` and `eslint` pass even when the production build fails.
---
## Actions Taken
- No code fixes applied. The failure is a structural Next.js `Suspense` requirement, not a trivial one-line lint fix.
- Ran all three verification commands on 2026-06-18.
- No code changes applied; documentation updated with results.
+42 -31
View File
@@ -1,39 +1,50 @@
# OpenFlare 前端路由验证报告
> **验证日期**:2026-06-18
> **对照**:`20260618-openflare-wavelet-frontend-migration.md` §6.1(FC-1 ~ FC-20)
> **验证日期**:2026-06-18(复验)
> **对照**:`20260618-openflare-wavelet-frontend-migration.md` §6.1(FC-1 ~ FC-20)
> **扫描路径**:`Wavelet/frontend/app/(main)/openflare/**/page.tsx`
## 摘要
| 指标 | 结果 |
|---|---|
| 计划路由数(FC-1 ~ FC-20) | 20 |
| `page.tsx` 文件数 | 20 |
| **路由文件覆盖率** | **100%** |
| **严格完成**(路由存在且非占位) | **19/20(95%)** |
| **加权完成**(FC-1 计 0.5) | **97.5%** |
| FC-20 `/openflare/about` | ✅ 已实现 |
| 缺失路由 | **无** |
## 路由对照
| # | 计划路由 | 状态 | 备注 |
|---|---|---|---|
| FC-1 | `/openflare` | ⚠️ partial | 仪表盘已实现;世界地图以国家列表替代 |
| FC-2 | `/openflare/nodes` | ✅ | |
| FC-3 | `/openflare/nodes/detail` | ✅ | Edge/Relay/Tunnel 三种详情 |
| FC-4 | `/openflare/proxy-routes` | ✅ | |
| FC-5 | `/openflare/proxy-routes/detail` | ✅ | 6 Section + 发布 |
| FC-6 | `/openflare/config-versions` | ✅ | |
| FC-7 | `/openflare/waf` | ✅ | |
| FC-8 | `/openflare/waf/ip-groups` | ✅ | 页内链接 |
| FC-9 | `/openflare/websites` | ✅ | |
| FC-10 | `/openflare/websites/detail` | ✅ | |
| FC-11 | `/openflare/websites/certificates` | ✅ | |
| FC-12 | `/openflare/websites/dns-accounts` | ✅ | |
| FC-13 | `/openflare/pages` | ✅ | |
| FC-14 | `/openflare/pages/detail` | ✅ | |
| FC-15 | `/openflare/origins` | ✅ | |
| FC-16 | `/openflare/origins/detail` | ✅ | |
| FC-17 | `/openflare/access-logs` | ✅ | 4 Tab |
| FC-18 | `/openflare/apply-logs` | ✅ | |
| FC-19 | `/openflare/performance` | ✅ | |
| FC-20 | `/openflare/about` | ❌ | 待实现或复用 `/docs` |
## 覆盖率
- **严格完成**:18/20(90%)
- **加权**:92.5%(FC-1 计 0.5)
- **PlaceholderPage 使用**:0(组件仍存在但未引用)
| # | 计划路由 | 页面文件 | 状态 | 备注 |
|---|---|---|---|---|
| FC-1 | `/openflare` | `openflare/page.tsx` | ⚠️ partial | 仪表盘已实现;世界地图以 `GeoDistributionList` 国家列表替代 |
| FC-2 | `/openflare/nodes` | `openflare/nodes/page.tsx` | ✅ | |
| FC-3 | `/openflare/nodes/detail` | `openflare/nodes/detail/page.tsx` | ✅ | Edge / Relay / Tunnel |
| FC-4 | `/openflare/proxy-routes` | `openflare/proxy-routes/page.tsx` | ✅ | |
| FC-5 | `/openflare/proxy-routes/detail` | `openflare/proxy-routes/detail/page.tsx` | ✅ | 6 Section + 发布 |
| FC-6 | `/openflare/config-versions` | `openflare/config-versions/page.tsx` | ✅ | |
| FC-7 | `/openflare/waf` | `openflare/waf/page.tsx` | ✅ | |
| FC-8 | `/openflare/waf/ip-groups` | `openflare/waf/ip-groups/page.tsx` | ✅ | |
| FC-9 | `/openflare/websites` | `openflare/websites/page.tsx` | ✅ | |
| FC-10 | `/openflare/websites/detail` | `openflare/websites/detail/page.tsx` | ✅ | |
| FC-11 | `/openflare/websites/certificates` | `openflare/websites/certificates/page.tsx` | ✅ | |
| FC-12 | `/openflare/websites/dns-accounts` | `openflare/websites/dns-accounts/page.tsx` | ✅ | |
| FC-13 | `/openflare/pages` | `openflare/pages/page.tsx` | ✅ | |
| FC-14 | `/openflare/pages/detail` | `openflare/pages/detail/page.tsx` | ✅ | |
| FC-15 | `/openflare/origins` | `openflare/origins/page.tsx` | ✅ | |
| FC-16 | `/openflare/origins/detail` | `openflare/origins/detail/page.tsx` | ✅ | |
| FC-17 | `/openflare/access-logs` | `openflare/access-logs/page.tsx` | ✅ | 4 Tab |
| FC-18 | `/openflare/apply-logs` | `openflare/apply-logs/page.tsx` | ✅ | |
| FC-19 | `/openflare/performance` | `openflare/performance/page.tsx` | ✅ | |
| FC-20 | `/openflare/about` | `openflare/about/page.tsx` | ✅ | AboutService + 侧栏导航 |
## 子路由(不在侧栏顶级)
`/openflare/nodes/detail`、`proxy-routes/detail`、`pages/detail`、`websites/detail`、`websites/certificates`、`websites/dns-accounts`、`waf/ip-groups`、`origins/detail`
`/openflare/nodes/detail`、`proxy-routes/detail`、`pages/detail`、`websites/detail`、`websites/certificates`、`websites/dns-accounts`、`waf/ip-groups`、`origins/detail`
## 结论
§6.1 路由映射 **基本完成**(20/20 文件)。唯一路由级缺口为 FC-1 世界地图(可选 P2);无缺失 `page.tsx`。
+29 -10
View File
@@ -1,8 +1,16 @@
# OpenFlare 前端 Service 层验证报告
> **验证日期**:2026-06-18
> **验证日期**:2026-06-18(复验)
> **目录**:`Wavelet/frontend/lib/services/openflare/`
## 覆盖率摘要
| 维度 | 结果 |
|---|---|
| §7.1 计划服务文件 | **15 / 15 = 100%** |
| 业务 `/api/*` 端点 | **100%** |
| 待补 API | **0** |
## §7.1 对照结果
| 计划服务 | 实际文件 | 注册 | 方法覆盖 |
@@ -21,22 +29,33 @@
| access-log | ✅ | `openflareAccessLog` | ✅ |
| apply-log | ✅ | `openflareApplyLog` | ✅ |
| option | ✅ | `openflareOption` | ✅ |
| update | ⚠️ partial | `openflareUpdate` | 仅 `getLatestRelease` |
| update | ✅ | `openflareUpdate` | ✅ 6 方法 |
| about | ✅ | `openflareAbout` | `getAboutContent()` |
## update.service.ts 复验
| 方法 | 端点 |
|---|---|
| `getLatestRelease(channel?)` | `GET /api/update/latest-release` |
| `upgradeServer(channel?)` | `POST /api/update/upgrade` |
| `uploadServerBinary(file, onProgress?)` | `POST /api/update/manual-upload` |
| `confirmManualServerUpgrade(token)` | `POST /api/update/manual-upgrade` |
| `createUpgradeLogsWebSocket()` | `WS /api/update/logs/ws` |
| `parseUpgradeStreamSnapshot(raw)` | WS 消息解析 |
已接入:`use-openflare-server-upgrade.ts`、`openflare-version-entry.tsx`、`openflare-ops.tsx`。
## 计划外(已实现)
- `status.service.ts` → `openflareStatus`
- `uptimekuma.service.ts` → `openflareUptimeKuma`
- `about.service.ts` → `openflareAbout`
- `legacy-base.service.ts`(基类)
## 待补 API
| 端点 | 说明 |
|---|---|
| `POST /api/update/upgrade` | 顶栏升级流程 |
| `POST /api/update/manual-upload` | 手动上传 |
| `POST /api/update/manual-upgrade` | 手动确认升级 |
| `WS /api/update/logs/ws` | 升级日志流 |
| `GET /api/about` | About 页(FC-20) |
无。
业务 `/api/*` 其余端点均已由 Service 覆盖。
## 结论
Service 层迁移 **完成**。所有计划内业务 API 均已由 Service 覆盖。
+25 -18
View File
@@ -1,6 +1,6 @@
# OpenFlare 前端 UI 规范验收报告
> **验证日期**:2026-06-18
> **验证日期**:2026-06-18(polish pass 复验)
> **范围**:`Wavelet/frontend/app/(main)/openflare/`
## 总体
@@ -9,25 +9,32 @@
|---|---|
| 标题 `h1 text-2xl font-semibold tracking-tight` | ✅ |
| 外壳 `py-6 px-1` | ✅ |
| shadcn 组件(无旧 UI) | ✅ |
| 单文件 ≤600 行 | ✅(最大 access-logs 584 行) |
| loading/empty/error | ⚠️ 部分 Tab 缺 error |
| RHF + Zod 表单 | ⚠️ 部分 Dialog 待补 |
| shadcn 组件(无旧 UI / HeroUI) | ✅ |
| 单文件 ≤600 行 | ✅(最大 `access-logs/page.tsx` 558 行) |
| loading/empty/error | ✅ |
| RHF + Zod 表单(P1 Dialog) | ✅ |
**总体结论:PASS**
## 分模块
| 模块 | 结论 |
|---|---|
| Dashboard | ✅ PASS |
| Nodes | ⚠️ `node-editor-dialog` 缺 Zod |
| Proxy detail | ⚠️ 无效 ID/error 态待统一组件 |
| WAF | ⚠️ `rule-entry-dialog` 缺 RHF+Zod |
| Websites/Certificates | ⚠️ `dns-account-create-dialog` 缺 Zod |
| Access logs | ⚠️ folds/ip-summary Tab 缺 error |
| Config versions | ⚠️ cleanup dialog 缺 Zod |
| 模块 | 结论 | 说明 |
|---|---|---|
| Dashboard | ✅ PASS | 标准壳与三态组件 |
| Nodes | ✅ PASS | `node-editor-dialog` Zod 已补 |
| Proxy detail | ✅ PASS | `ErrorInline` / `EmptyStateWithBorder` 已统一 |
| Proxy list | ⚠️ PASS* | 列表 error 仅 toast(P2) |
| WAF | ✅ PASS | `rule-entry-dialog` RHF+Zod 已补 |
| Websites/Certificates | ✅ PASS | `dns-account-create-dialog` Zod 已补 |
| Access logs | ✅ PASS | folds/ip-summary/ip-trend error 已补 |
| Config versions | ✅ PASS | `cleanup-dialog` Zod 已补 |
| About | ✅ PASS | 页面惯例完整 |
| Version upgrade dialog | ✅ PASS | Card/Badge/Markdown 惯例合规 |
## P1 修复建议
## P2 可选改进
1. `proxy-routes/detail/page-client.tsx` — `EmptyStateWithBorder` / `ErrorInline`
2. `access-logs/page.tsx` — folds、ip-summary 的 `isError` 分支
3. `node-editor-dialog.tsx`、`rule-entry-dialog.tsx` — 补 Zod schema
1. `proxy-routes/page-client.tsx` — 列表 error 改用 `ErrorInline`
2. `access-logs/components/cleanup-dialog.tsx` — 补 RHF+Zod
3. `access-logs/page.tsx` ip-trend — 改用 shadcn `Input`
4. `nodes/components/node-observability.tsx` — 改用 `ErrorInline`+重试
5. FC-1 世界地图 — 当前以国家列表替代