This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -53,8 +53,16 @@ interface AuthSectionProps {
onSavingChange?: (saving: boolean) => void;
}
export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
export function AuthSection({
route,
onRouteUpdate,
onSavingChange,
}: AuthSectionProps) {
const { saving, save } = useRouteSectionSave(
route,
onRouteUpdate,
onSavingChange,
);
const form = useForm<AuthValues>({
resolver: zodResolver(authSchema),
@@ -77,24 +85,28 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
return (
<SectionShell
title="认证配置"
description="配置 Basic Auth 限制未授权访问。PoW 防护请在 WAF 规则组中配置。"
title='认证配置'
description='配置 Basic Auth 限制未授权访问。PoW 防护请在 WAF 规则组中配置。'
formId={proxyRouteFormIds.auth}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.auth}
className="space-y-5"
className='space-y-5'
onSubmit={form.handleSubmit(
async (values) => {
await save(
{
basic_auth_enabled: values.auth_mode === 'basic',
basic_auth_username:
values.auth_mode === 'basic' ? values.basic_auth_username.trim() : '',
values.auth_mode === 'basic'
? values.basic_auth_username.trim()
: '',
basic_auth_password:
values.auth_mode === 'basic' ? values.basic_auth_password.trim() : '',
values.auth_mode === 'basic'
? values.basic_auth_password.trim()
: '',
},
'认证配置已保存',
);
@@ -106,7 +118,7 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
>
<FormField
control={form.control}
name="auth_mode"
name='auth_mode'
render={({ field }) => (
<FormItem>
<FormLabel>认证模式</FormLabel>
@@ -126,11 +138,13 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">无认证</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value='none'>无认证</SelectItem>
<SelectItem value='basic'>Basic Auth</SelectItem>
</SelectContent>
</Select>
<FormDescription>PoW 防护仅支持在 WAF 规则组中启用并绑定站点。</FormDescription>
<FormDescription>
PoW 防护仅支持在 WAF 规则组中启用并绑定站点。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -140,12 +154,12 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
<>
<FormField
control={form.control}
name="basic_auth_username"
name='basic_auth_username'
render={({ field }) => (
<FormItem>
<FormLabel>账号</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
<Input placeholder='admin' {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -154,12 +168,12 @@ export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectio
<FormField
control={form.control}
name="basic_auth_password"
name='basic_auth_password'
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type="text" placeholder="secret123" {...field} />
<Input type='text' placeholder='secret123' {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -5,27 +5,13 @@ import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {linesFromTextarea, validateCacheRules} from '../../components/helpers';
import {linesFromTextarea, validateCacheRules,} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
@@ -60,14 +46,23 @@ interface CacheSectionProps {
onSavingChange?: (saving: boolean) => void;
}
export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
export function CacheSection({
route,
onRouteUpdate,
onSavingChange,
}: CacheSectionProps) {
const { saving, save } = useRouteSectionSave(
route,
onRouteUpdate,
onSavingChange,
);
const form = useForm<CacheValues>({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
@@ -75,7 +70,8 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
@@ -103,23 +99,27 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
return (
<SectionShell
title="缓存"
description="保留现有安全绕过逻辑,只对当前站点生效。"
title='缓存'
description='保留现有安全绕过逻辑,只对当前站点生效。'
formId={proxyRouteFormIds.cache}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.cache}
className="space-y-5"
className='space-y-5'
onSubmit={form.handleSubmit(async (values) => {
const rules = linesFromTextarea(values.cache_rules_text);
await save(
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_policy: values.cache_enabled
? values.cache_policy
: 'url',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
values.cache_enabled && values.cache_policy !== 'url'
? rules
: [],
},
'缓存设置已保存',
);
@@ -127,17 +127,21 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
>
<FormField
control={form.control}
name="cache_enabled"
name='cache_enabled'
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie 的请求。
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie
的请求。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -145,7 +149,7 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
<FormField
control={form.control}
name="cache_policy"
name='cache_policy'
render={({ field }) => (
<FormItem>
<FormLabel>缓存策略</FormLabel>
@@ -160,10 +164,10 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="url">按 URL 缓存</SelectItem>
<SelectItem value="suffix">按后缀缓存</SelectItem>
<SelectItem value="path_prefix">按路径前缀缓存</SelectItem>
<SelectItem value="path_exact">按精确路径缓存</SelectItem>
<SelectItem value='url'>按 URL 缓存</SelectItem>
<SelectItem value='suffix'>按后缀缓存</SelectItem>
<SelectItem value='path_prefix'>按路径前缀缓存</SelectItem>
<SelectItem value='path_exact'>按精确路径缓存</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -173,13 +177,13 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
<FormField
control={form.control}
name="cache_rules_text"
name='cache_rules_text'
render={({ field }) => (
<FormItem>
<FormLabel>缓存规则</FormLabel>
<FormControl>
<Textarea
className="min-h-32"
className='min-h-32'
disabled={!watchedEnabled || watchedPolicy === 'url'}
placeholder={rulesPlaceholder}
{...field}
@@ -194,4 +198,4 @@ export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSect
</Form>
</SectionShell>
);
}
}
@@ -10,7 +10,7 @@ import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, Form
import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
import {zoneQueryKey, ZoneService} from '@/lib/services/openflare';
import {listAllZoneDomains} from '../../components/helpers';
import {ZoneDomainSelector} from '../../components/zone-domain-selector';
@@ -18,17 +18,18 @@ import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const domainSettingsSchema = z
.object({
site_name: z
.string()
.trim()
.min(1, '请输入站点标识')
.max(255, '站点标识不能超过 255 个字符'),
zone_domain_ids: z.array(z.number().int().positive()).min(1, '请至少选择一个域名'),
enabled: z.boolean(),
redirect_http: z.boolean(),
});
const domainSettingsSchema = z.object({
site_name: z
.string()
.trim()
.min(1, '请输入站点标识')
.max(255, '站点标识不能超过 255 个字符'),
zone_domain_ids: z
.array(z.number().int().positive())
.min(1, '请至少选择一个域名'),
enabled: z.boolean(),
redirect_http: z.boolean(),
});
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
@@ -38,8 +39,16 @@ interface DomainSectionProps {
onSavingChange?: (saving: boolean) => void;
}
export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
export function DomainSection({
route,
onRouteUpdate,
onSavingChange,
}: DomainSectionProps) {
const { saving, save } = useRouteSectionSave(
route,
onRouteUpdate,
onSavingChange,
);
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
@@ -98,23 +107,26 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
.filter((item): item is NonNullable<typeof item> => item != null);
}, [domainsQuery.data, route.id, route.zone_domains, selectedIDs]);
const hasCertificate = selectedDomains.some((domain) => domain.cert_id != null);
const hasCertificate = selectedDomains.some(
(domain) => domain.cert_id != null,
);
return (
<SectionShell
title="域名设置"
description="绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。"
title='域名设置'
description='绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。'
formId={proxyRouteFormIds.domains}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.domains}
className="space-y-5"
className='space-y-5'
onSubmit={form.handleSubmit(async (values) => {
if (values.redirect_http && !hasCertificate) {
form.setError('redirect_http', {
message: '启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)',
message:
'启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)',
});
return;
}
@@ -133,15 +145,20 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
>
<FormField
control={form.control}
name="enabled"
name='enabled'
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<FormLabel>启用站点</FormLabel>
<FormDescription>关闭后会保留配置,但不会参与发布。</FormDescription>
<FormDescription>
关闭后会保留配置,但不会参与发布。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -149,14 +166,16 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
<FormField
control={form.control}
name="site_name"
name='site_name'
render={({ field }) => (
<FormItem>
<FormLabel>站点标识</FormLabel>
<FormControl>
<Input placeholder="marketing-site" {...field} />
<Input placeholder='marketing-site' {...field} />
</FormControl>
<FormDescription>建议使用稳定、可读的业务标识,不必与域名完全一致。</FormDescription>
<FormDescription>
建议使用稳定、可读的业务标识,不必与域名完全一致。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -164,7 +183,7 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
<FormField
control={form.control}
name="zone_domain_ids"
name='zone_domain_ids'
render={({ field }) => (
<FormItem>
<FormLabel>绑定域名</FormLabel>
@@ -182,8 +201,8 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
/>
</FormControl>
<FormDescription>
从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建 FQDN
并自动勾选。
从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建
FQDN 并自动勾选。
</FormDescription>
<FormMessage />
</FormItem>
@@ -192,10 +211,10 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
<FormField
control={form.control}
name="redirect_http"
name='redirect_http'
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
<FormDescription>
{hasCertificate
@@ -1,18 +1,29 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import type { ProxyRouteItem } from '@/lib/services/openflare';
import {normalizeLimitRate, validateLimitRate} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
import {
normalizeLimitRate,
validateLimitRate,
} from '../../components/helpers';
import { proxyRouteFormIds } from '../helpers';
import { useRouteSectionSave } from '../hooks/use-route-section-save';
import { SectionShell } from './section-shell';
const rateLimitSchema = z
.object({
@@ -21,7 +32,10 @@ const rateLimitSchema = z
limit_rate: z.string(),
})
.superRefine((value, context) => {
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
for (const field of [
'limit_conn_per_server',
'limit_conn_per_ip',
] as const) {
const rawValue = value[field].trim();
if (!rawValue) {
continue;
@@ -53,8 +67,16 @@ interface LimitsSectionProps {
onSavingChange?: (saving: boolean) => void;
}
export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
export function LimitsSection({
route,
onRouteUpdate,
onSavingChange,
}: LimitsSectionProps) {
const { saving, save } = useRouteSectionSave(
route,
onRouteUpdate,
onSavingChange,
);
const form = useForm<RateLimitValues>({
resolver: zodResolver(rateLimitSchema),
@@ -62,7 +84,9 @@ export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSe
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_conn_per_ip: route.limit_conn_per_ip
? String(route.limit_conn_per_ip)
: '',
limit_rate: route.limit_rate || '',
},
});
@@ -72,27 +96,33 @@ export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSe
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_conn_per_ip: route.limit_conn_per_ip
? String(route.limit_conn_per_ip)
: '',
limit_rate: route.limit_rate || '',
});
}, [form, route]);
return (
<SectionShell
title="流量限制"
description="站点限流,空值或 0 表示关闭。"
title='流量限制'
description='站点限流,空值或 0 表示关闭。'
formId={proxyRouteFormIds.limits}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.limits}
className="grid gap-5 md:grid-cols-2"
className='grid gap-5 md:grid-cols-2'
onSubmit={form.handleSubmit(async (values) => {
await save(
{
limit_conn_per_server: Number(values.limit_conn_per_server.trim() || '0'),
limit_conn_per_ip: Number(values.limit_conn_per_ip.trim() || '0'),
limit_conn_per_server: Number(
values.limit_conn_per_server.trim() || '0',
),
limit_conn_per_ip: Number(
values.limit_conn_per_ip.trim() || '0',
),
limit_rate: normalizeLimitRate(values.limit_rate),
},
'流量限制已保存',
@@ -101,12 +131,12 @@ export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSe
>
<FormField
control={form.control}
name="limit_conn_per_server"
name='limit_conn_per_server'
render={({ field }) => (
<FormItem>
<FormLabel>并发限制</FormLabel>
<FormControl>
<Input placeholder="120" {...field} />
<Input placeholder='120' {...field} />
</FormControl>
<FormDescription>限制当前站点最大并发连接数。</FormDescription>
<FormMessage />
@@ -116,12 +146,12 @@ export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSe
<FormField
control={form.control}
name="limit_conn_per_ip"
name='limit_conn_per_ip'
render={({ field }) => (
<FormItem>
<FormLabel>单 IP 限制</FormLabel>
<FormControl>
<Input placeholder="12" {...field} />
<Input placeholder='12' {...field} />
</FormControl>
<FormDescription>限制单个 IP 的最大并发数。</FormDescription>
<FormMessage />
@@ -131,14 +161,16 @@ export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSe
<FormField
control={form.control}
name="limit_rate"
name='limit_rate'
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className='md:col-span-2'>
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder="512k/1m" {...field} />
<Input placeholder='512k/1m' {...field} />
</FormControl>
<FormDescription>限制单请求带宽,例如 512k 或 1m。</FormDescription>
<FormDescription>
限制单请求带宽,例如 512k 或 1m。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -1,18 +1,32 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {NodeService, PagesService} from '@/lib/services/openflare';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { ProxyRouteItem } from '@/lib/services/openflare';
import { NodeService, PagesService } from '@/lib/services/openflare';
import {
customHeadersToText,
@@ -21,9 +35,9 @@ import {
parseOriginUrls,
validateOriginHost,
} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
import { proxyRouteFormIds } from '../helpers';
import { useRouteSectionSave } from '../hooks/use-route-section-save';
import { SectionShell } from './section-shell';
const reverseProxySchema = z
.object({
@@ -86,7 +100,9 @@ const reverseProxySchema = z
});
}
const { error: headerError } = parseCustomHeadersText(value.custom_headers_text);
const { error: headerError } = parseCustomHeadersText(
value.custom_headers_text,
);
if (headerError) {
context.addIssue({
code: z.ZodIssueCode.custom,
@@ -104,8 +120,16 @@ interface ProxySectionProps {
onSavingChange?: (saving: boolean) => void;
}
export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
export function ProxySection({
route,
onRouteUpdate,
onSavingChange,
}: ProxySectionProps) {
const { saving, save } = useRouteSectionSave(
route,
onRouteUpdate,
onSavingChange,
);
const tunnelsQuery = useQuery({
queryKey: ['openflare', 'nodes'],
@@ -134,7 +158,9 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
pages_project_id: route.pages_project_id
? String(route.pages_project_id)
: '',
custom_headers_text: customHeadersToText(route.custom_header_list),
},
});
@@ -148,7 +174,9 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
pages_project_id: route.pages_project_id
? String(route.pages_project_id)
: '',
custom_headers_text: customHeadersToText(route.custom_header_list),
});
}, [form, route]);
@@ -157,15 +185,15 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
return (
<SectionShell
title="反向代理"
description="配置请求回源上游的策略与地址。"
title='反向代理'
description='配置请求回源上游的策略与地址。'
formId={proxyRouteFormIds.proxy}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.proxy}
className="space-y-5"
className='space-y-5'
onSubmit={form.handleSubmit(async (values) => {
let originUrl = '';
let originScheme: 'http' | 'https' = 'http';
@@ -194,7 +222,9 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
originPort = '80';
}
const { headers } = parseCustomHeadersText(values.custom_headers_text);
const { headers } = parseCustomHeadersText(
values.custom_headers_text,
);
await save(
{
@@ -213,9 +243,13 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
values.upstream_type === 'tunnel'
? values.tunnel_target_addr
: '',
tunnel_target_protocol:
values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
@@ -227,11 +261,11 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
>
<FormField
control={form.control}
name="upstream_type"
name='upstream_type'
render={({ field }) => (
<FormItem className="space-y-3">
<FormItem className='space-y-3'>
<FormLabel>回源方式</FormLabel>
<div className="flex flex-wrap gap-4">
<div className='flex flex-wrap gap-4'>
{(
[
['direct', '直连上游'],
@@ -239,15 +273,18 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
['pages', 'Pages 静态站点'],
] as const
).map(([value, label]) => (
<label key={value} className="flex cursor-pointer items-center gap-2 text-sm">
<label
key={value}
className='flex cursor-pointer items-center gap-2 text-sm'
>
<input
type="radio"
type='radio'
value={value}
checked={field.value === value}
onChange={() => field.onChange(value)}
className="size-4 accent-primary"
className='size-4 accent-primary'
/>
<Label className="font-normal">{label}</Label>
<Label className='font-normal'>{label}</Label>
</label>
))}
</div>
@@ -259,13 +296,13 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
{upstreamType === 'direct' ? (
<FormField
control={form.control}
name="origin_urls_text"
name='origin_urls_text'
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Textarea
className="min-h-40 font-mono text-xs"
className='min-h-40 font-mono text-xs'
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
@@ -273,7 +310,9 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
/>
</FormControl>
<FormDescription>
每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。
每行一个完整
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
path 或 query。
</FormDescription>
<FormMessage />
</FormItem>
@@ -282,29 +321,37 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
) : null}
{upstreamType === 'tunnel' ? (
<div className="space-y-4 rounded-lg border border-dashed bg-muted/30 p-4">
<div className='space-y-4 rounded-lg border border-dashed bg-muted/30 p-4'>
<FormField
control={form.control}
name="tunnel_id"
name='tunnel_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
field.onChange(value === 'none' ? '' : value)
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
<SelectValue placeholder='请选择...' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
<SelectItem value='none'>请选择...</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem key={tunnel.id} value={String(tunnel.id)}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
{tunnel.name} (
{tunnel.status === 'online' ? '在线' : '离线'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>将请求转发到该隧道连接的客户端节点。</FormDescription>
<FormDescription>
将请求转发到该隧道连接的客户端节点。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -312,7 +359,7 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
<FormField
control={form.control}
name="tunnel_target_protocol"
name='tunnel_target_protocol'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
@@ -323,8 +370,8 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
<SelectItem value='http'>HTTP</SelectItem>
<SelectItem value='https'>HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -334,14 +381,16 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
<FormField
control={form.control}
name="tunnel_target_addr"
name='tunnel_target_addr'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1:8080" {...field} />
<Input placeholder='127.0.0.1:8080' {...field} />
</FormControl>
<FormDescription>例如: 127.0.0.1:8080 或 192.168.1.10:80</FormDescription>
<FormDescription>
例如: 127.0.0.1:8080 或 192.168.1.10:80
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -350,29 +399,39 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
) : null}
{upstreamType === 'pages' ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4">
<div className='rounded-lg border border-dashed bg-muted/30 p-4'>
<FormField
control={form.control}
name="pages_project_id"
name='pages_project_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
field.onChange(value === 'none' ? '' : value)
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
<SelectValue placeholder='请选择...' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
<SelectItem value='none'>请选择...</SelectItem>
{pagesProjects.map((project) => (
<SelectItem key={project.id} value={String(project.id)}>
<SelectItem
key={project.id}
value={String(project.id)}
>
{project.name} ({project.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>仅显示已启用且已有激活部署的 Pages 项目。</FormDescription>
<FormDescription>
仅显示已启用且已有激活部署的 Pages 项目。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -382,14 +441,16 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
<FormField
control={form.control}
name="origin_host"
name='origin_host'
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormControl>
<Input placeholder="origin.example.internal" {...field} />
<Input placeholder='origin.example.internal' {...field} />
</FormControl>
<FormDescription>留空时默认透传访问域名 $host。</FormDescription>
<FormDescription>
留空时默认透传访问域名 $host。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -397,13 +458,13 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
<FormField
control={form.control}
name="custom_headers_text"
name='custom_headers_text'
render={({ field }) => (
<FormItem>
<FormLabel>自定义请求头</FormLabel>
<FormControl>
<Textarea
className="min-h-32 font-mono text-xs"
className='min-h-32 font-mono text-xs'
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
{...field}
/>
@@ -1,9 +1,9 @@
'use client';
import {useRouter} from 'next/navigation';
import {useCallback, useState} from 'react';
import {ArrowLeft, Loader2, Route, Upload} from 'lucide-react';
import {toast} from 'sonner';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react';
import { ArrowLeft, Loader2, Route, Upload } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -14,12 +14,15 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import type {ConfigDiffResult, ProxyRouteItem} from '@/lib/services/openflare';
import {ConfigVersionService} from '@/lib/services/openflare';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type {
ConfigDiffResult,
ProxyRouteItem,
} from '@/lib/services/openflare';
import { ConfigVersionService } from '@/lib/services/openflare';
import {getErrorMessage} from '../../components/helpers';
import { getErrorMessage } from '../../components/helpers';
function hasConfigDiff(diff: ConfigDiffResult) {
return (
@@ -90,39 +93,41 @@ export function RouteHeader({ route }: RouteHeaderProps) {
return (
<>
<div className="space-y-4">
<div className='space-y-4'>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-0 text-xs"
variant='ghost'
size='sm'
className='h-8 gap-1.5 px-0 text-xs'
onClick={() => router.back()}
>
<ArrowLeft className="size-3.5" />
<ArrowLeft className='size-3.5' />
返回
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{route.site_name}</h1>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<Route className='size-5 text-primary' />
<div className='flex flex-wrap items-center gap-2'>
<h1 className='text-2xl font-semibold tracking-tight'>
{route.site_name}
</h1>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
size='sm'
className='h-8 gap-1.5 text-xs'
disabled={publishing || diffLoading}
onClick={() => void handlePublishClick()}
>
{publishing || diffLoading ? (
<Loader2 className="size-3.5 animate-spin" />
<Loader2 className='size-3.5 animate-spin' />
) : (
<Upload className="size-3.5" />
<Upload className='size-3.5' />
)}
发布配置
</Button>
@@ -130,7 +135,10 @@ export function RouteHeader({ route }: RouteHeaderProps) {
</div>
</div>
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<AlertDialog
open={publishConfirmOpen}
onOpenChange={setPublishConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
@@ -141,11 +149,15 @@ export function RouteHeader({ route }: RouteHeaderProps) {
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button onClick={() => void handlePublish()} disabled={publishing}>
{publishing ? <Loader2 className="size-4 animate-spin" /> : '确认发布'}
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认发布'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
}
@@ -1,9 +1,15 @@
'use client';
import type {ReactNode} from 'react';
import type { ReactNode } from 'react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface SectionShellProps {
title: string;
@@ -22,16 +28,22 @@ export function SectionShell({
}: SectionShellProps) {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div className="space-y-1">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
<div className='space-y-1'>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<Button type="submit" form={formId} size="sm" className="h-8 shrink-0 text-xs" disabled={saving}>
<Button
type='submit'
form={formId}
size='sm'
className='h-8 shrink-0 text-xs'
disabled={saving}
>
{saving ? '保存中...' : '保存'}
</Button>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
}
@@ -1,6 +1,6 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
closestCenter,
DndContext,
@@ -17,14 +17,14 @@ import {
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {ArrowDown, ArrowUp, GripVertical} from 'lucide-react';
import {toast} from 'sonner';
import { CSS } from '@dnd-kit/utilities';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Field,
FieldDescription,
@@ -33,15 +33,15 @@ import {
FieldLegend,
FieldSet,
} from '@/components/ui/field';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { ProxyRouteItem } from '@/lib/services/openflare';
import { WafService } from '@/lib/services/openflare';
import {getErrorMessage} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {SectionShell} from './section-shell';
import { getErrorMessage } from '../../components/helpers';
import { proxyRouteFormIds } from '../helpers';
import { SectionShell } from './section-shell';
interface WafSectionProps {
route: ProxyRouteItem;
@@ -56,50 +56,57 @@ interface SortableRuleProps {
onMove: (from: number, to: number) => void;
}
export function reorderRuleIDs(ids: number[], activeID: number, overID: number) {
export function reorderRuleIDs(
ids: number[],
activeID: number,
overID: number,
) {
const from = ids.indexOf(activeID);
const to = ids.indexOf(overID);
return from < 0 || to < 0 || from === to ? ids : arrayMove(ids, from, to);
}
function SortableRule({id, name, index, total, onMove}: SortableRuleProps) {
const {attributes, listeners, setNodeRef, transform, transition} = useSortable({id});
function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
return (
<div
ref={setNodeRef}
style={{transform: CSS.Transform.toString(transform), transition}}
className="flex items-center gap-2 rounded-lg border p-3"
style={{ transform: CSS.Transform.toString(transform), transition }}
className='flex items-center gap-2 rounded-lg border p-3'
>
<Button
type="button"
variant="ghost"
size="icon-sm"
type='button'
variant='ghost'
size='icon-sm'
aria-label={`拖动${name}`}
{...attributes}
{...listeners}
>
<GripVertical data-icon="inline-start" />
<GripVertical data-icon='inline-start' />
</Button>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{name}</span>
<span className='min-w-0 flex-1 truncate text-sm font-medium'>
{name}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
type='button'
variant='ghost'
size='icon-sm'
aria-label={`上移${name}`}
disabled={index === 0}
onClick={() => onMove(index, index - 1)}
>
<ArrowUp data-icon="inline-start" />
<ArrowUp data-icon='inline-start' />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
type='button'
variant='ghost'
size='icon-sm'
aria-label={`下移${name}`}
disabled={index === total - 1}
onClick={() => onMove(index, index + 1)}
>
<ArrowDown data-icon="inline-start" />
<ArrowDown data-icon='inline-start' />
</Button>
</div>
);
@@ -110,7 +117,9 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const wafQuery = useQuery({
@@ -119,7 +128,8 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
});
const wafMutation = useMutation({
mutationFn: (ids: number[]) => WafService.updateSiteRuleGroups(route.id, ids),
mutationFn: (ids: number[]) =>
WafService.updateSiteRuleGroups(route.id, ids),
onMutate: () => {
onSavingChange?.(true);
},
@@ -133,8 +143,12 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
}),
queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'rule-groups'],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions', 'diff'],
}),
]);
},
onError: (error) => {
@@ -150,7 +164,10 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const ruleMap = useMemo(
() => new Map((wafQuery.data?.rule_groups ?? []).map((rule) => [rule.id, rule])),
() =>
new Map(
(wafQuery.data?.rule_groups ?? []).map((rule) => [rule.id, rule]),
),
[wafQuery.data?.rule_groups],
);
@@ -159,7 +176,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
setSelectedIDs((current) => arrayMove(current, from, to));
};
const handleDragEnd = ({active, over}: DragEndEvent) => {
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setSelectedIDs((current) =>
reorderRuleIDs(current, Number(active.id), Number(over.id)),
@@ -168,13 +185,13 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
return (
<SectionShell
title="WAF"
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。"
title='WAF'
description='全局规则组始终生效;这里可以为当前网站叠加自定义规则组。'
formId={proxyRouteFormIds.waf}
saving={wafMutation.isPending}
>
{wafQuery.isLoading ? (
<LoadingStateWithBorder description="加载 WAF 规则组..." />
<LoadingStateWithBorder description='加载 WAF 规则组...' />
) : wafQuery.isError ? (
<ErrorInline
message={getErrorMessage(wafQuery.error)}
@@ -183,39 +200,47 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
) : (
<form
id={proxyRouteFormIds.waf}
className="flex flex-col gap-5"
className='flex flex-col gap-5'
onSubmit={(event) => {
event.preventDefault();
wafMutation.mutate(selectedIDs);
}}
>
{wafQuery.data?.global_rule_group ? (
<div className="rounded-lg border bg-muted/30 p-4" data-testid="global-waf-rule">
<div className="flex items-center justify-between gap-3">
<div
className='rounded-lg border bg-muted/30 p-4'
data-testid='global-waf-rule'
>
<div className='flex items-center justify-between gap-3'>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<p className='text-[10px] font-semibold uppercase tracking-wider text-muted-foreground'>
Global Rule Group
</p>
<p className="mt-1 text-sm font-semibold">
<p className='mt-1 text-sm font-semibold'>
{wafQuery.data.global_rule_group.name}
</p>
</div>
<Badge variant="outline">始终生效</Badge>
<Badge variant='outline'>始终生效</Badge>
</div>
</div>
) : null}
<FieldSet>
<FieldLegend variant="label">选择自定义规则</FieldLegend>
<FieldDescription>选中的规则会按下方执行顺序依次运行。</FieldDescription>
<FieldGroup data-slot="checkbox-group" className="grid gap-3 md:grid-cols-2">
<FieldLegend variant='label'>选择自定义规则</FieldLegend>
<FieldDescription>
选中的规则会按下方执行顺序依次运行。
</FieldDescription>
<FieldGroup
data-slot='checkbox-group'
className='grid gap-3 md:grid-cols-2'
>
{(wafQuery.data?.rule_groups ?? []).map((group) => {
const checkboxID = `waf-rule-${group.id}`;
return (
<Field
key={group.id}
orientation="horizontal"
className="rounded-lg border p-4"
orientation='horizontal'
className='rounded-lg border p-4'
>
<Checkbox
id={checkboxID}
@@ -231,10 +256,12 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
/>
<FieldLabel
htmlFor={checkboxID}
className="min-w-0 cursor-pointer flex-col items-start gap-1"
className='min-w-0 cursor-pointer flex-col items-start gap-1'
>
<span className="truncate text-sm font-semibold">{group.name}</span>
<span className="text-xs text-muted-foreground">
<span className='truncate text-sm font-semibold'>
{group.name}
</span>
<span className='text-xs text-muted-foreground'>
{group.enabled ? '启用中' : '已停用'} ·{' '}
{group.graph.nodes.length} 个节点
</span>
@@ -246,9 +273,9 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
</FieldSet>
{selectedIDs.length > 0 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">执行顺序</p>
<p className="text-xs text-muted-foreground">
<div className='flex flex-col gap-2'>
<p className='text-sm font-medium'>执行顺序</p>
<p className='text-xs text-muted-foreground'>
自定义规则按此顺序执行,可拖动或使用上下移动按钮调整。
</p>
<DndContext
@@ -256,8 +283,11 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={selectedIDs} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
<SortableContext
items={selectedIDs}
strategy={verticalListSortingStrategy}
>
<div className='flex flex-col gap-2'>
{selectedIDs.map((id, index) => {
const rule = ruleMap.get(id);
return rule ? (
@@ -278,7 +308,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
) : null}
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
<EmptyStateWithBorder description="暂无自定义 WAF 规则组" />
<EmptyStateWithBorder description='暂无自定义 WAF 规则组' />
) : null}
</form>
)}
@@ -1,4 +1,4 @@
import type {ProxyRouteConfigSection} from '@/lib/services/openflare';
import type { ProxyRouteConfigSection } from '@/lib/services/openflare';
export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
domains: 'proxy-route-domains-form',
@@ -1,12 +1,18 @@
'use client';
import {useCallback, useState} from 'react';
import {toast} from 'sonner';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type {ProxyRouteItem, ProxyRouteMutationPayload} from '@/lib/services/openflare';
import {ProxyRouteService} from '@/lib/services/openflare';
import type {
ProxyRouteItem,
ProxyRouteMutationPayload,
} from '@/lib/services/openflare';
import { ProxyRouteService } from '@/lib/services/openflare';
import {buildPayloadFromRoute, getErrorMessage} from '../../components/helpers';
import {
buildPayloadFromRoute,
getErrorMessage,
} from '../../components/helpers';
export function useRouteSectionSave(
route: ProxyRouteItem,
@@ -37,4 +43,4 @@ export function useRouteSectionSave(
);
return { saving, save };
}
}
@@ -1,23 +1,29 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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';
import {ProxyRouteService} from '@/lib/services/openflare';
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';
import { ProxyRouteService } from '@/lib/services/openflare';
import {getProxyRouteConfigSection, proxyRouteConfigSections} from '../components/helpers';
import {AuthSection} from './components/auth-section';
import {CacheSection} from './components/cache-section';
import {DomainSection} from './components/domain-section';
import {LimitsSection} from './components/limits-section';
import {ProxySection} from './components/proxy-section';
import {RouteHeader} from './components/route-header';
import {WafSection} from './components/waf-section';
import {
getProxyRouteConfigSection,
proxyRouteConfigSections,
} from '../components/helpers';
import { AuthSection } from './components/auth-section';
import { CacheSection } from './components/cache-section';
import { DomainSection } from './components/domain-section';
import { LimitsSection } from './components/limits-section';
import { ProxySection } from './components/proxy-section';
import { RouteHeader } from './components/route-header';
import { WafSection } from './components/waf-section';
export function ProxyRouteDetailPageClient() {
const router = useRouter();
@@ -71,39 +77,36 @@ export function ProxyRouteDetailPageClient() {
if (!hasValidId) {
return (
<div className="py-6 px-1">
<ErrorInline message="缺少有效的规则 ID。" />
<div className='py-6 px-1'>
<ErrorInline message='缺少有效的规则 ID。' />
</div>
);
}
if (loading) {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<Skeleton className='h-64 w-full' />
</div>
);
}
if (loadError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={loadError}
onRetry={() => void fetchRoute()}
/>
<div className='py-6 px-1'>
<ErrorInline message={loadError} onRetry={() => void fetchRoute()} />
</div>
);
}
if (!route) {
return (
<div className="py-6 px-1">
<div className='py-6 px-1'>
<EmptyStateWithBorder
title="未找到对应规则"
description="请返回列表重试。"
title='未找到对应规则'
description='请返回列表重试。'
/>
</div>
);
@@ -115,45 +118,47 @@ export function ProxyRouteDetailPageClient() {
};
return (
<div className="py-6 px-1 space-y-6">
<div className='py-6 px-1 space-y-6'>
<RouteHeader route={route} />
<Tabs
value={activeSection}
onValueChange={(value) => handleSectionChange(value as ProxyRouteConfigSection)}
className="w-full"
onValueChange={(value) =>
handleSectionChange(value as ProxyRouteConfigSection)
}
className='w-full'
>
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
{proxyRouteConfigSections.map((section) => (
<TabsTrigger
key={section.key}
value={section.key}
className="px-0 pb-2 text-xs font-semibold"
className='px-0 pb-2 text-xs font-semibold'
>
{section.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="domains" className="focus-visible:outline-none">
<TabsContent value='domains' className='focus-visible:outline-none'>
<DomainSection {...sectionProps} />
</TabsContent>
<TabsContent value="limits" className="focus-visible:outline-none">
<TabsContent value='limits' className='focus-visible:outline-none'>
<LimitsSection {...sectionProps} />
</TabsContent>
<TabsContent value="proxy" className="focus-visible:outline-none">
<TabsContent value='proxy' className='focus-visible:outline-none'>
<ProxySection {...sectionProps} />
</TabsContent>
<TabsContent value="cache" className="focus-visible:outline-none">
<TabsContent value='cache' className='focus-visible:outline-none'>
<CacheSection {...sectionProps} />
</TabsContent>
<TabsContent value="waf" className="focus-visible:outline-none">
<TabsContent value='waf' className='focus-visible:outline-none'>
<WafSection route={route} />
</TabsContent>
<TabsContent value="auth" className="focus-visible:outline-none">
<TabsContent value='auth' className='focus-visible:outline-none'>
<AuthSection {...sectionProps} />
</TabsContent>
</Tabs>
</div>
);
}
}
@@ -1,15 +1,15 @@
import {Suspense} from 'react';
import { Suspense } from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import { Skeleton } from '@/components/ui/skeleton';
import {ProxyRouteDetailPageClient} from './page-client';
import { ProxyRouteDetailPageClient } from './page-client';
function DetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<Skeleton className='h-64 w-full' />
</div>
);
}