mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 18:26:36 +08:00
refactor(web): select route domains from zones
反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与 旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
This commit is contained in:
@@ -3,13 +3,15 @@ import type {
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
import {ZoneService} from '@/lib/services/openflare';
|
||||
|
||||
export const proxyRouteConfigSections = [
|
||||
{
|
||||
key: 'domains' as const,
|
||||
label: '域名设置',
|
||||
description: '维护站点标识、域名列表和证书绑定。',
|
||||
description: '维护站点标识,并从 Zone 中选择绑定域名。',
|
||||
},
|
||||
{
|
||||
key: 'limits' as const,
|
||||
@@ -77,6 +79,20 @@ export function parseOriginUrl(originUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Domain FQDNs bound to a proxy route (from zone_domains). */
|
||||
export function getRouteDomainNames(route: ProxyRouteItem): string[] {
|
||||
return (route.zone_domains ?? []).map((item) => item.domain).filter(Boolean);
|
||||
}
|
||||
|
||||
export function getRoutePrimaryDomain(route: ProxyRouteItem): string {
|
||||
return getRouteDomainNames(route)[0] ?? '';
|
||||
}
|
||||
|
||||
export function getRouteDomainsLabel(route: ProxyRouteItem): string {
|
||||
const names = getRouteDomainNames(route);
|
||||
return names.length > 0 ? names.join(', ') : '未绑定域名';
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
if (route.upstream_type === 'pages') {
|
||||
return route.pages_project_id
|
||||
@@ -88,7 +104,7 @@ export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
const target = route.tunnel_target_addr || '未配置目标';
|
||||
return `Tunnel → ${protocol}://${target}`;
|
||||
}
|
||||
if (route.upstream_list.length <= 1) {
|
||||
if ((route.upstream_list ?? []).length <= 1) {
|
||||
return route.origin_url;
|
||||
}
|
||||
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
|
||||
@@ -284,12 +300,11 @@ export function buildPayloadFromRoute(
|
||||
const primaryOrigin =
|
||||
route.upstream_type === 'pages'
|
||||
? parseOriginUrl('http://127.0.0.1')
|
||||
: parseOriginUrl(route.origin_url);
|
||||
: parseOriginUrl(route.origin_url || 'http://127.0.0.1');
|
||||
|
||||
return {
|
||||
site_name: route.site_name,
|
||||
domain: route.primary_domain,
|
||||
domains: route.domains,
|
||||
zone_domain_ids: route.zone_domain_ids ?? [],
|
||||
origin_id: null,
|
||||
origin_url: route.origin_url,
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
@@ -297,20 +312,17 @@ export function buildPayloadFromRoute(
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams: route.upstream_list.slice(1),
|
||||
upstreams: (route.upstream_list ?? []).slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id,
|
||||
cert_ids: route.cert_ids,
|
||||
domain_cert_ids: route.domain_cert_ids,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
limit_rate: route.limit_rate,
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
cache_rules: route.cache_rule_list ?? [],
|
||||
custom_headers: route.custom_header_list ?? [],
|
||||
remark: route.remark || '',
|
||||
basic_auth_enabled: route.basic_auth_enabled,
|
||||
basic_auth_username: route.basic_auth_username,
|
||||
@@ -323,3 +335,10 @@ export function buildPayloadFromRoute(
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load all Zone domains across registered Zones for route binding selectors. */
|
||||
export async function listAllZoneDomains(): Promise<ZoneDomainItem[]> {
|
||||
const zones = await ZoneService.list();
|
||||
const overviews = await Promise.all(zones.map((zone) => ZoneService.getOverview(zone.id)));
|
||||
return overviews.flatMap((overview) => overview.domains ?? []);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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';
|
||||
|
||||
@@ -12,28 +13,20 @@ import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTi
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
import {parseOriginUrl, validateDomain} from './helpers';
|
||||
import {listAllZoneDomains, parseOriginUrl} from './helpers';
|
||||
import {ZoneDomainSelector} from './zone-domain-selector';
|
||||
|
||||
const createProxyRouteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domain: z.string().trim().min(1, '请输入域名'),
|
||||
zone_domain_ids: z.array(z.number().int().positive()).min(1, '请至少选择一个域名'),
|
||||
origin_url: z.string().trim().min(1, '请输入上游地址'),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domainError = validateDomain(value.domain);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value.origin_url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
@@ -56,7 +49,7 @@ type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
|
||||
|
||||
const defaultValues: CreateProxyRouteFormValues = {
|
||||
site_name: '',
|
||||
domain: '',
|
||||
zone_domain_ids: [],
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
@@ -78,6 +71,18 @@ export function ProxyRouteCreateSheet({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, 'all-domains'],
|
||||
queryFn: () => listAllZoneDomains(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
@@ -85,14 +90,17 @@ export function ProxyRouteCreateSheet({
|
||||
}, [form, open]);
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const domain = values.domain.trim().toLowerCase();
|
||||
const origin = parseOriginUrl(values.origin_url.trim());
|
||||
const selectedDomains = (domainsQuery.data ?? []).filter((domain) =>
|
||||
values.zone_domain_ids.includes(domain.id),
|
||||
);
|
||||
const primaryDomain = selectedDomains[0]?.domain ?? '';
|
||||
const hasCert = selectedDomains.some((domain) => domain.cert_id != null);
|
||||
|
||||
try {
|
||||
const route = await ProxyRouteService.create({
|
||||
site_name: values.site_name.trim() || domain,
|
||||
domain,
|
||||
domains: [domain],
|
||||
site_name: values.site_name.trim() || primaryDomain,
|
||||
zone_domain_ids: values.zone_domain_ids,
|
||||
origin_id: null,
|
||||
origin_url: values.origin_url.trim(),
|
||||
origin_scheme: origin.scheme,
|
||||
@@ -102,10 +110,7 @@ export function ProxyRouteCreateSheet({
|
||||
origin_host: '',
|
||||
upstreams: [],
|
||||
enabled: values.enabled,
|
||||
enable_https: false,
|
||||
cert_id: null,
|
||||
cert_ids: [],
|
||||
domain_cert_ids: [0],
|
||||
enable_https: hasCert,
|
||||
redirect_http: false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
@@ -135,7 +140,7 @@ export function ProxyRouteCreateSheet({
|
||||
<SheetHeader>
|
||||
<SheetTitle>新建规则</SheetTitle>
|
||||
<SheetDescription>
|
||||
创建网站后可进入详情页继续配置 HTTPS、缓存和限流等高级选项。
|
||||
从已注册的 Zone 域名中选择绑定关系,创建后可继续配置缓存和限流等高级选项。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -150,7 +155,7 @@ export function ProxyRouteCreateSheet({
|
||||
<FormControl>
|
||||
<Input placeholder="marketing-site" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>可选,留空时会自动使用域名。</FormDescription>
|
||||
<FormDescription>可选,留空时会自动使用首个域名。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -158,13 +163,22 @@ export function ProxyRouteCreateSheet({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
name="zone_domain_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>主域名</FormLabel>
|
||||
<FormLabel>绑定域名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="www.example.com" {...field} />
|
||||
<ZoneDomainSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
domains={domainsQuery.data ?? []}
|
||||
zones={zonesQuery.data ?? []}
|
||||
disabled={domainsQuery.isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
域名与证书在 Zone 中维护;此处只选择本站点要绑定的 FQDN。
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -177,9 +191,8 @@ export function ProxyRouteCreateSheet({
|
||||
<FormItem>
|
||||
<FormLabel>上游地址</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://origin.internal:443" {...field} />
|
||||
<Input placeholder="http://127.0.0.1:8080" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>主回源完整 URL,创建后可在详情页添加多上游。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -191,8 +204,8 @@ export function ProxyRouteCreateSheet({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>创建后立即启用</FormLabel>
|
||||
<FormDescription>关闭后站点会以草稿保存。</FormDescription>
|
||||
<FormLabel>启用站点</FormLabel>
|
||||
<FormDescription>关闭后会保留配置,但不会参与发布。</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
@@ -208,20 +221,23 @@ export function ProxyRouteCreateSheet({
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
<Textarea placeholder="可选备注" rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root?.message ? (
|
||||
{form.formState.errors.root ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? '创建中...' : '创建'}
|
||||
{form.formState.isSubmitting ? '创建中…' : '创建'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ExternalLink} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {ZoneDomainItem, ZoneItem} from '@/lib/services/openflare';
|
||||
|
||||
export interface ZoneDomainSelectorProps {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
domains: ZoneDomainItem[];
|
||||
zones?: ZoneItem[];
|
||||
/** Current route ID: domains bound to this route remain selectable. */
|
||||
currentRouteId?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ZoneDomainSelector({
|
||||
value,
|
||||
onChange,
|
||||
domains,
|
||||
zones = [],
|
||||
currentRouteId = null,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ZoneDomainSelectorProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const zoneMap = useMemo(
|
||||
() => new Map(zones.map((zone) => [zone.id, zone.domain])),
|
||||
[zones],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
const list = [...domains].sort((a, b) => a.domain.localeCompare(b.domain));
|
||||
if (!normalized) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((domain) => {
|
||||
const zoneRoot = zoneMap.get(domain.zone_id) ?? '';
|
||||
return (
|
||||
domain.domain.toLowerCase().includes(normalized) ||
|
||||
zoneRoot.toLowerCase().includes(normalized) ||
|
||||
String(domain.id).includes(normalized)
|
||||
);
|
||||
});
|
||||
}, [domains, keyword, zoneMap]);
|
||||
|
||||
const toggle = (domain: ZoneDomainItem) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const boundElsewhere =
|
||||
domain.proxy_route_id != null &&
|
||||
domain.proxy_route_id !== currentRouteId;
|
||||
if (boundElsewhere) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.has(domain.id)) {
|
||||
onChange(value.filter((id) => id !== domain.id));
|
||||
return;
|
||||
}
|
||||
onChange([...value, domain.id].sort((a, b) => a - b));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索域名或 Zone…"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{domains.length === 0 ? (
|
||||
<>
|
||||
暂无可用 Zone 域名。请先在{' '}
|
||||
<Link href="/websites" className="text-primary underline-offset-4 hover:underline">
|
||||
网站管理
|
||||
</Link>{' '}
|
||||
中添加 FQDN。
|
||||
</>
|
||||
) : (
|
||||
'没有匹配的域名'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2">
|
||||
{filtered.map((domain) => {
|
||||
const boundElsewhere =
|
||||
domain.proxy_route_id != null &&
|
||||
domain.proxy_route_id !== currentRouteId;
|
||||
const checked = selected.has(domain.id);
|
||||
const zoneRoot = zoneMap.get(domain.zone_id);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={domain.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 transition-colors hover:bg-muted/60',
|
||||
boundElsewhere && 'cursor-not-allowed opacity-60',
|
||||
checked && !boundElsewhere && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled || boundElsewhere}
|
||||
onCheckedChange={() => toggle(domain)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{domain.domain}</span>
|
||||
{domain.cert_id ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
证书 #{domain.cert_id}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
无证书
|
||||
</Badge>
|
||||
)}
|
||||
{boundElsewhere ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
路由 #{domain.proxy_route_id}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{zoneRoot ? (
|
||||
<Link
|
||||
href={`/websites/${domain.zone_id}`}
|
||||
className="inline-flex items-center gap-0.5 hover:text-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Zone {zoneRoot}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span>Zone #{domain.zone_id}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user