mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(proxy-routes): align create form with upstream type selection
Let new proxy rules choose direct, tunnel, or Pages origin the same way as the detail reverse-proxy section, instead of only accepting a single upstream URL.
This commit is contained in:
@@ -17,6 +17,14 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -26,14 +34,17 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||||
import {
|
import {
|
||||||
|
NodeService,
|
||||||
|
PagesService,
|
||||||
ProxyRouteService,
|
ProxyRouteService,
|
||||||
ZoneService,
|
ZoneService,
|
||||||
zoneQueryKey,
|
zoneQueryKey,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
import { listAllZoneDomains, parseOriginUrl } from './helpers';
|
import { listAllZoneDomains, parseOriginUrl, parseOriginUrls } from './helpers';
|
||||||
import { ZoneDomainSelector } from './zone-domain-selector';
|
import { ZoneDomainSelector } from './zone-domain-selector';
|
||||||
|
|
||||||
const createProxyRouteSchema = z
|
const createProxyRouteSchema = z
|
||||||
@@ -42,24 +53,56 @@ const createProxyRouteSchema = z
|
|||||||
zone_domain_ids: z
|
zone_domain_ids: z
|
||||||
.array(z.number().int().positive())
|
.array(z.number().int().positive())
|
||||||
.min(1, '请至少选择一个域名'),
|
.min(1, '请至少选择一个域名'),
|
||||||
origin_url: z.string().trim().min(1, '请输入上游地址'),
|
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||||
|
origin_urls_text: z.string().trim(),
|
||||||
|
tunnel_id: z.string().optional(),
|
||||||
|
tunnel_target_addr: z.string().trim().optional(),
|
||||||
|
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||||
|
pages_project_id: z.string().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
try {
|
if (value.upstream_type === 'direct') {
|
||||||
const parsed = new URL(value.origin_url);
|
if (!value.origin_urls_text.trim()) {
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['origin_url'],
|
path: ['origin_urls_text'],
|
||||||
message: '上游地址必须以 http:// 或 https:// 开头',
|
message: '请至少填写一个上游地址',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||||
|
if (error) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['origin_urls_text'],
|
||||||
|
message: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.upstream_type === 'tunnel') {
|
||||||
|
if (!value.tunnel_id) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['tunnel_id'],
|
||||||
|
message: '请选择内网穿透隧道',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
if (!value.tunnel_target_addr?.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['tunnel_target_addr'],
|
||||||
|
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value.pages_project_id) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['origin_url'],
|
path: ['pages_project_id'],
|
||||||
message: '上游地址格式不合法',
|
message: '请选择 Pages 项目',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,7 +112,12 @@ type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
|
|||||||
const defaultValues: CreateProxyRouteFormValues = {
|
const defaultValues: CreateProxyRouteFormValues = {
|
||||||
site_name: '',
|
site_name: '',
|
||||||
zone_domain_ids: [],
|
zone_domain_ids: [],
|
||||||
origin_url: '',
|
upstream_type: 'direct',
|
||||||
|
origin_urls_text: '',
|
||||||
|
tunnel_id: '',
|
||||||
|
tunnel_target_addr: '',
|
||||||
|
tunnel_target_protocol: 'http',
|
||||||
|
pages_project_id: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +149,27 @@ export function ProxyRouteCreateSheet({
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tunnelsQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'nodes'],
|
||||||
|
queryFn: () => NodeService.listNodes(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagesProjectsQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'pages-projects'],
|
||||||
|
queryFn: () => PagesService.listProjects(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tunnelClients = (tunnelsQuery.data ?? []).filter(
|
||||||
|
(node) => node.node_type === 'tunnel_client',
|
||||||
|
);
|
||||||
|
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
|
||||||
|
(project) => project.enabled && project.active_deployment_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const upstreamType = form.watch('upstream_type');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
@@ -108,7 +177,33 @@ export function ProxyRouteCreateSheet({
|
|||||||
}, [form, open]);
|
}, [form, open]);
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async (values) => {
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
const origin = parseOriginUrl(values.origin_url.trim());
|
let originUrl = '';
|
||||||
|
let originScheme: 'http' | 'https' = 'http';
|
||||||
|
let originAddress = '';
|
||||||
|
let originPort = '';
|
||||||
|
let originUri = '';
|
||||||
|
let upstreams: string[] = [];
|
||||||
|
|
||||||
|
if (values.upstream_type === 'direct') {
|
||||||
|
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||||
|
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||||
|
originUrl = urls[0];
|
||||||
|
originScheme = primaryOrigin.scheme;
|
||||||
|
originAddress = primaryOrigin.address;
|
||||||
|
originPort = primaryOrigin.port;
|
||||||
|
originUri = primaryOrigin.uri;
|
||||||
|
upstreams = urls.slice(1);
|
||||||
|
} else if (values.upstream_type === 'tunnel') {
|
||||||
|
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
||||||
|
originScheme = values.tunnel_target_protocol as 'http' | 'https';
|
||||||
|
originAddress = values.tunnel_target_addr || '';
|
||||||
|
} else {
|
||||||
|
originUrl = 'http://127.0.0.1';
|
||||||
|
originScheme = 'http';
|
||||||
|
originAddress = '127.0.0.1';
|
||||||
|
originPort = '80';
|
||||||
|
}
|
||||||
|
|
||||||
const selectedDomains = (domainsQuery.data ?? []).filter((domain) =>
|
const selectedDomains = (domainsQuery.data ?? []).filter((domain) =>
|
||||||
values.zone_domain_ids.includes(domain.id),
|
values.zone_domain_ids.includes(domain.id),
|
||||||
);
|
);
|
||||||
@@ -120,13 +215,13 @@ export function ProxyRouteCreateSheet({
|
|||||||
site_name: values.site_name.trim() || primaryDomain,
|
site_name: values.site_name.trim() || primaryDomain,
|
||||||
zone_domain_ids: values.zone_domain_ids,
|
zone_domain_ids: values.zone_domain_ids,
|
||||||
origin_id: null,
|
origin_id: null,
|
||||||
origin_url: values.origin_url.trim(),
|
origin_url: originUrl,
|
||||||
origin_scheme: origin.scheme,
|
origin_scheme: originScheme,
|
||||||
origin_address: origin.address,
|
origin_address: originAddress,
|
||||||
origin_port: origin.port,
|
origin_port: originPort,
|
||||||
origin_uri: origin.uri,
|
origin_uri: originUri,
|
||||||
origin_host: '',
|
origin_host: '',
|
||||||
upstreams: [],
|
upstreams,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
enable_https: hasCert,
|
enable_https: hasCert,
|
||||||
redirect_http: false,
|
redirect_http: false,
|
||||||
@@ -138,7 +233,21 @@ export function ProxyRouteCreateSheet({
|
|||||||
cache_rules: [],
|
cache_rules: [],
|
||||||
custom_headers: [],
|
custom_headers: [],
|
||||||
basic_auth_enabled: false,
|
basic_auth_enabled: false,
|
||||||
upstream_type: 'direct',
|
upstream_type: values.upstream_type,
|
||||||
|
tunnel_node_id:
|
||||||
|
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||||
|
? Number(values.tunnel_id)
|
||||||
|
: null,
|
||||||
|
tunnel_target_addr:
|
||||||
|
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
||||||
|
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)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
@@ -200,9 +309,6 @@ export function ProxyRouteCreateSheet({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
勾选已登记域名,或使用「快捷新增域名」创建后自动勾选。
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -210,18 +316,187 @@ export function ProxyRouteCreateSheet({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='origin_url'
|
name='upstream_type'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className='space-y-3'>
|
||||||
<FormLabel>上游地址</FormLabel>
|
<FormLabel>回源方式</FormLabel>
|
||||||
<FormControl>
|
<div className='flex flex-wrap gap-4'>
|
||||||
<Input placeholder='http://127.0.0.1:8080' {...field} />
|
{(
|
||||||
</FormControl>
|
[
|
||||||
|
['direct', '直连上游'],
|
||||||
|
['tunnel', '内网穿透 (Tunnel)'],
|
||||||
|
['pages', 'Pages 静态站点'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className='flex cursor-pointer items-center gap-2 text-sm'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='radio'
|
||||||
|
value={value}
|
||||||
|
checked={field.value === value}
|
||||||
|
onChange={() => field.onChange(value)}
|
||||||
|
className='size-4 accent-primary'
|
||||||
|
/>
|
||||||
|
<Label className='font-normal'>{label}</Label>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{upstreamType === 'direct' ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='origin_urls_text'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>上游地址</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className='min-h-32 font-mono text-xs'
|
||||||
|
placeholder={
|
||||||
|
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
每行一个完整
|
||||||
|
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
|
||||||
|
path 或 query。
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{upstreamType === 'tunnel' ? (
|
||||||
|
<div className='space-y-4 rounded-lg border border-dashed bg-muted/30 p-4'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='tunnel_id'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>选择内网穿透隧道</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || 'none'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'none' ? '' : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='请选择...' />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='none'>请选择...</SelectItem>
|
||||||
|
{tunnelClients.map((tunnel) => (
|
||||||
|
<SelectItem
|
||||||
|
key={tunnel.id}
|
||||||
|
value={String(tunnel.id)}
|
||||||
|
>
|
||||||
|
{tunnel.name} (
|
||||||
|
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
将请求转发到该隧道连接的客户端节点。
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='tunnel_target_protocol'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>内网服务协议</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='http'>HTTP</SelectItem>
|
||||||
|
<SelectItem value='https'>HTTPS</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='tunnel_target_addr'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>内网服务地址</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder='127.0.0.1:8080' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
例如: 127.0.0.1:8080 或 192.168.1.10:80
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{upstreamType === 'pages' ? (
|
||||||
|
<div className='rounded-lg border border-dashed bg-muted/30 p-4'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='pages_project_id'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>选择 Pages 项目</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || 'none'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'none' ? '' : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='请选择...' />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='none'>请选择...</SelectItem>
|
||||||
|
{pagesProjects.map((project) => (
|
||||||
|
<SelectItem
|
||||||
|
key={project.id}
|
||||||
|
value={String(project.id)}
|
||||||
|
>
|
||||||
|
{project.name} ({project.slug})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='enabled'
|
name='enabled'
|
||||||
|
|||||||
@@ -4559,4 +4559,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user