refactor(web): select route domains from zones

反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与
旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
This commit is contained in:
ryan
2026-07-12 15:23:48 +08:00
parent 350b433cc1
commit 1160d5846a
10 changed files with 415 additions and 476 deletions
@@ -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>
);
}