mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
refactor(web): select route domains from zones
反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与 旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user