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
@@ -48,11 +48,13 @@ export function UptimeKumaSiteSelectModal({
const routes = routesQuery.data ?? []
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) return routes
return routes.filter(
(route) =>
return routes.filter((route) => {
const domains = (route.zone_domains ?? []).map((item) => item.domain).join(' ')
return (
route.site_name.toLowerCase().includes(keyword) ||
route.primary_domain.toLowerCase().includes(keyword),
)
domains.toLowerCase().includes(keyword)
)
})
}, [routesQuery.data, searchTerm])
const toggleSite = (siteName: string) => {
@@ -147,7 +149,9 @@ export function UptimeKumaSiteSelectModal({
/>
</td>
<td className="px-3 py-2 font-medium">{route.site_name}</td>
<td className="px-3 py-2 text-muted-foreground">{route.primary_domain}</td>
<td className="px-3 py-2 text-muted-foreground">
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') || '—'}
</td>
</tr>
)
})}
@@ -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>
);
}
@@ -1,262 +0,0 @@
'use client';
import {useId, useMemo} from 'react';
import {Link2, Minus, Plus} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {WebsiteService} from '@/lib/services/openflare';
import {validateDomain} from '../../components/helpers';
import type {DomainListRow} from '../helpers';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(input: string, sources: string[], rows: DomainListRow[]) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
interface DomainListInputProps {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: DomainListInputProps) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
const applyManagedDomainCertificate = async (index: number, domain: string) => {
const normalized = domain.trim().toLowerCase();
if (!normalized || validateDomain(normalized)) {
return;
}
try {
const result = await WebsiteService.match(normalized);
if (!result.matched || !result.candidate?.certificate_id) {
return;
}
const nextRows = safeRows.slice();
if (!nextRows[index].certificateId) {
nextRows[index] = {
...nextRows[index],
certificateId: String(result.candidate.certificate_id),
};
updateRows(nextRows);
}
} catch {
// Ignore match failures; user can still pick a certificate manually.
}
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(row.domain, normalizedSources, safeRows).slice(
0,
4,
);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[40px_minmax(0,1fr)_220px] md:items-start">
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
disabled={safeRows.length === 1}
aria-label={`删除域名输入框 ${index + 1}`}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(safeRows.filter((_, rowIndex) => rowIndex !== index));
}}
>
<Minus className="size-3.5" />
</Button>
<div className="min-w-0 space-y-2">
<Input
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
onBlur={() => {
onBlur?.();
void applyManagedDomainCertificate(index, row.domain);
}}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="rounded-full border px-2.5 py-0.5 text-[11px] text-muted-foreground transition hover:border-primary hover:text-foreground"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
void applyManagedDomainCertificate(index, suggestion);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<Select
value={row.certificateId || 'none'}
onValueChange={(value) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: value === 'none' ? '' : value,
};
updateRows(nextRows);
}}
>
<SelectTrigger className="h-9 w-full">
<SelectValue
placeholder={certificates.length === 0 ? '暂无可选证书' : '选择证书'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{certificates.length === 0 ? '暂无可选证书' : '不绑定证书'}
</SelectItem>
{certificates.map((certificate) => (
<SelectItem key={certificate.id} value={String(certificate.id)}>
{certificate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus className="size-3.5" />
添加域名
</Button>
<Badge variant="outline" className="gap-1 text-[10px] font-normal">
<Link2 className="size-3" />
输入域名后将自动匹配托管域名证书
</Badge>
</div>
</div>
);
}
@@ -10,17 +10,12 @@ 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 {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
import {validateDomains} from '../../components/helpers';
import {
buildDomainCertificateIDs,
buildDomainRows,
normalizeSelectedCertificateIDs,
proxyRouteFormIds,
} from '../helpers';
import {listAllZoneDomains} from '../../components/helpers';
import {ZoneDomainSelector} from '../../components/zone-domain-selector';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {DomainListInput} from './domain-list-input';
import {SectionShell} from './section-shell';
const domainSettingsSchema = z
@@ -30,42 +25,9 @@ const domainSettingsSchema = z
.trim()
.min(1, '请输入站点标识')
.max(255, '站点标识不能超过 255 个字符'),
domain_rows: z
.array(
z.object({
domain: z.string(),
certificateId: z.string(),
}),
)
.min(1),
zone_domain_ids: z.array(z.number().int().positive()).min(1, '请至少选择一个域名'),
enabled: z.boolean(),
redirect_http: z.boolean(),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const error = validateDomains(domains);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: error,
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
@@ -79,21 +41,21 @@ interface DomainSectionProps {
export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const certificatesQuery = useQuery({
queryKey: ['openflare', 'tls-certificates'],
queryFn: () => TlsCertificateService.list(),
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
queryFn: () => ZoneService.list(),
});
const managedDomainsQuery = useQuery({
queryKey: ['openflare', 'managed-domains'],
queryFn: () => WebsiteService.list(),
const domainsQuery = useQuery({
queryKey: [...zoneQueryKey, 'all-domains'],
queryFn: () => listAllZoneDomains(),
});
const form = useForm<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema),
defaultValues: {
site_name: route.site_name,
domain_rows: buildDomainRows(route),
zone_domain_ids: route.zone_domain_ids ?? [],
enabled: route.enabled,
redirect_http: route.redirect_http,
},
@@ -102,26 +64,47 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
useEffect(() => {
form.reset({
site_name: route.site_name,
domain_rows: buildDomainRows(route),
zone_domain_ids: route.zone_domain_ids ?? [],
enabled: route.enabled,
redirect_http: route.redirect_http,
});
}, [form, route]);
const domainSuggestionSources = useMemo(
() => [
...(route.domains ?? []),
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[managedDomainsQuery.data, route.domains],
);
const selectedIDs = form.watch('zone_domain_ids');
const selectedDomains = useMemo(() => {
const fromApi = domainsQuery.data ?? [];
const byId = new Map(fromApi.map((domain) => [domain.id, domain]));
// Prefer live catalog; fall back to route-bound domains for display before catalog loads.
return selectedIDs
.map((id) => {
const catalog = byId.get(id);
if (catalog) {
return catalog;
}
const bound = (route.zone_domains ?? []).find((item) => item.id === id);
if (!bound) {
return null;
}
return {
id: bound.id,
zone_id: bound.zone_id,
proxy_route_id: route.id,
domain: bound.domain,
cert_id: bound.cert_id,
remark: '',
created_at: '',
updated_at: '',
};
})
.filter((item): item is NonNullable<typeof item> => item != null);
}, [domainsQuery.data, route.id, route.zone_domains, selectedIDs]);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(form.watch('domain_rows'));
const hasCertificate = selectedDomains.some((domain) => domain.cert_id != null);
return (
<SectionShell
title="域名设置"
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
description="绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。"
formId={proxyRouteFormIds.domains}
saving={saving}
>
@@ -130,23 +113,20 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
id={proxyRouteFormIds.domains}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
if (values.redirect_http && !hasCertificate) {
form.setError('redirect_http', {
message: '启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)',
});
return;
}
await save(
{
site_name: values.site_name.trim(),
domain: domains[0],
domains,
zone_domain_ids: values.zone_domain_ids,
enabled: values.enabled,
enable_https: certIDs.length > 0,
cert_id: certIDs[0] ?? null,
cert_ids: certIDs,
domain_cert_ids: domainCertIDs,
redirect_http: certIDs.length > 0 ? values.redirect_http : false,
enable_https: hasCertificate,
redirect_http: hasCertificate ? values.redirect_http : false,
},
'域名设置已保存',
);
@@ -185,21 +165,22 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
<FormField
control={form.control}
name="domain_rows"
name="zone_domain_ids"
render={({ field }) => (
<FormItem>
<FormLabel>域名列表</FormLabel>
<FormLabel>绑定域名</FormLabel>
<FormControl>
<DomainListInput
rows={field.value}
<ZoneDomainSelector
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={domainSuggestionSources}
certificates={certificatesQuery.data ?? []}
domains={domainsQuery.data ?? []}
zones={zonesQuery.data ?? []}
currentRouteId={route.id}
disabled={domainsQuery.isLoading}
/>
</FormControl>
<FormDescription>
每行配置一个域名。可为不同域名选择不同证书,托管域名将自动匹配证书。
仅选择绑定关系;证书与 FQDN 请到对应 Zone 详情页管理。
</FormDescription>
<FormMessage />
</FormItem>
@@ -214,15 +195,15 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
<div className="space-y-0.5">
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
<FormDescription>
{selectedCertificateIDs.length > 0
{hasCertificate
? '开启后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'}
: '所选域名至少绑定一张证书后才能启用。'}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={selectedCertificateIDs.length === 0}
disabled={!hasCertificate}
onCheckedChange={field.onChange}
/>
</FormControl>
@@ -1,9 +1,4 @@
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
export type DomainListRow = {
domain: string;
certificateId: string;
};
import type {ProxyRouteConfigSection} from '@/lib/services/openflare';
export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
domains: 'proxy-route-domains-form',
@@ -14,74 +9,6 @@ export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
auth: 'proxy-route-auth-form',
};
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
export function buildDomainRowsFromRoute(
domains: string[],
domainCertIDs: number[],
certIDs: number[],
): DomainListRow[] {
if (domains.length === 0) {
return ensureRows([]);
}
if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({
domain,
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
}));
}
if (certIDs.length === 0) {
return domains.map((domain) => ({ domain, certificateId: '' }));
}
if (certIDs.length === 1) {
return domains.map((domain) => ({
domain,
certificateId: String(certIDs[0]),
}));
}
return domains.map((domain, index) => ({
domain,
certificateId: certIDs[index] ? String(certIDs[index]) : '',
}));
}
export function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
export function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0 ? certificateID : 0;
});
}
export function buildDomainRows(route: ProxyRouteItem) {
const selectedCertIDs =
route.cert_ids.length > 0
? route.cert_ids
: route.cert_id
? [route.cert_id]
: [];
return buildDomainRowsFromRoute(route.domains, route.domain_cert_ids, selectedCertIDs);
}
export function submitProxyRouteSectionForm(section: ProxyRouteConfigSection) {
const form = document.getElementById(proxyRouteFormIds[section]);
if (form instanceof HTMLFormElement) {
@@ -89,4 +16,4 @@ export function submitProxyRouteSectionForm(section: ProxyRouteConfigSection) {
return true;
}
return false;
}
}
@@ -25,7 +25,12 @@ import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {ProxyRouteService} from '@/lib/services/openflare';
import {getUpstreamSummary} from './components/helpers';
import {
getRouteDomainNames,
getRouteDomainsLabel,
getRoutePrimaryDomain,
getUpstreamSummary,
} from './components/helpers';
import {ProxyRouteCreateSheet} from './components/proxy-route-create-sheet';
export function ProxyRoutesPageClient() {
@@ -64,8 +69,8 @@ export function ProxyRoutesPageClient() {
return routes.filter((route) => {
const haystack = [
route.site_name,
route.primary_domain,
...route.domains,
getRoutePrimaryDomain(route),
...getRouteDomainNames(route),
route.origin_url,
route.remark,
]
@@ -170,8 +175,11 @@ export function ProxyRoutesPageClient() {
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell className="font-medium">{route.site_name}</TableCell>
<TableCell className="max-w-[220px] truncate" title={route.domains.join(', ')}>
{route.primary_domain || route.domain}
<TableCell
className="max-w-[220px] truncate"
title={getRouteDomainsLabel(route)}
>
{getRoutePrimaryDomain(route) || getRouteDomainsLabel(route)}
</TableCell>
<TableCell>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
@@ -38,7 +38,10 @@ export function SiteBindingSheet({
const normalized = keyword.trim().toLowerCase();
if (!normalized) return routes;
return routes.filter((route) =>
[route.site_name, route.primary_domain, ...route.domains]
[
route.site_name,
...(route.zone_domains ?? []).map((item) => item.domain),
]
.join(' ')
.toLowerCase()
.includes(normalized),
@@ -109,7 +112,8 @@ export function SiteBindingSheet({
{route.site_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{route.domains.join(', ')}
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') ||
'未绑定域名'}
</span>
</span>
</button>
@@ -0,0 +1,81 @@
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {describe, expect, it, vi} from 'vitest';
import {ZoneDomainSelector} from '@/app/(main)/proxy-routes/components/zone-domain-selector';
import type {ZoneDomainItem, ZoneItem} from '@/lib/services/openflare';
vi.mock('next/link', () => ({
default: ({children, href}: {children: React.ReactNode; href: string}) => (
<a href={href}>{children}</a>
),
}));
const zones: ZoneItem[] = [
{id: 1, domain: 'arctel.de', remark: '', created_at: '', updated_at: ''},
];
const domains: ZoneDomainItem[] = [
{
id: 7,
zone_id: 1,
proxy_route_id: null,
domain: 'api.arctel.de',
cert_id: 9,
remark: '',
created_at: '',
updated_at: '',
},
{
id: 8,
zone_id: 1,
proxy_route_id: 99,
domain: 'bound.arctel.de',
cert_id: null,
remark: '',
created_at: '',
updated_at: '',
},
];
describe('ZoneDomainSelector', () => {
it('renders domains and toggles selection', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<ZoneDomainSelector
value={[7]}
onChange={onChange}
domains={domains}
zones={zones}
/>,
);
expect(screen.getByText('api.arctel.de')).toBeVisible();
expect(screen.getByText('bound.arctel.de')).toBeVisible();
expect(screen.getByText('路由 #99')).toBeVisible();
// Deselect already selected domain
await user.click(screen.getByText('api.arctel.de'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('does not select domains bound to another route', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<ZoneDomainSelector
value={[]}
onChange={onChange}
domains={domains}
zones={zones}
currentRouteId={1}
/>,
);
await user.click(screen.getByText('bound.arctel.de'));
expect(onChange).not.toHaveBeenCalled();
});
});