mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
refactor: remove unused remarks and add quick domain create
Drop remark fields from Zone, Zone domains, proxy routes, WAF rule groups and IP groups across models, APIs, UI and DB columns (keep certificate/origin remarks). Add quick-create domain input for short labels, @ apex and full FQDNs when binding domains.
This commit is contained in:
@@ -338,7 +338,6 @@ export function buildPayloadFromRoute(
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
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,
|
||||
basic_auth_password: route.basic_auth_password,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, Form
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
@@ -24,7 +23,6 @@ const createProxyRouteSchema = z
|
||||
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) => {
|
||||
try {
|
||||
@@ -52,7 +50,6 @@ const defaultValues: CreateProxyRouteFormValues = {
|
||||
zone_domain_ids: [],
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
interface ProxyRouteCreateSheetProps {
|
||||
@@ -120,7 +117,6 @@ export function ProxyRouteCreateSheet({
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
basic_auth_enabled: false,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: 'direct',
|
||||
});
|
||||
|
||||
@@ -174,10 +170,13 @@ export function ProxyRouteCreateSheet({
|
||||
domains={domainsQuery.data ?? []}
|
||||
zones={zonesQuery.data ?? []}
|
||||
disabled={domainsQuery.isLoading}
|
||||
onDomainCreated={async () => {
|
||||
await domainsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
域名与证书在 Zone 中维护;此处只选择本站点要绑定的 FQDN。
|
||||
勾选已登记域名,或使用「快捷新增域名」创建后自动勾选。
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -214,20 +213,6 @@ export function ProxyRouteCreateSheet({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="可选备注" rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ExternalLink} from 'lucide-react';
|
||||
import {ExternalLink, Plus} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
import {QuickCreateZoneDomainDialog} from '../../websites/components/quick-create-zone-domain-dialog';
|
||||
|
||||
export interface ZoneDomainSelectorProps {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
@@ -19,6 +22,8 @@ export interface ZoneDomainSelectorProps {
|
||||
currentRouteId?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Called after a domain is created so parent can refresh the catalog. */
|
||||
onDomainCreated?: (domain: ZoneDomainItem) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ZoneDomainSelector({
|
||||
@@ -29,17 +34,31 @@ export function ZoneDomainSelector({
|
||||
currentRouteId = null,
|
||||
disabled = false,
|
||||
className,
|
||||
onDomainCreated,
|
||||
}: ZoneDomainSelectorProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const zoneMap = useMemo(
|
||||
() => new Map(zones.map((zone) => [zone.id, zone.domain])),
|
||||
[zones],
|
||||
);
|
||||
|
||||
/** Hide domains already bound to another route; keep unbound + current route. */
|
||||
const availableDomains = useMemo(
|
||||
() =>
|
||||
domains.filter(
|
||||
(domain) =>
|
||||
domain.proxy_route_id == null || domain.proxy_route_id === currentRouteId,
|
||||
),
|
||||
[currentRouteId, domains],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
const list = [...domains].sort((a, b) => a.domain.localeCompare(b.domain));
|
||||
const list = [...availableDomains].sort((a, b) =>
|
||||
a.domain.localeCompare(b.domain),
|
||||
);
|
||||
if (!normalized) {
|
||||
return list;
|
||||
}
|
||||
@@ -51,16 +70,16 @@ export function ZoneDomainSelector({
|
||||
String(domain.id).includes(normalized)
|
||||
);
|
||||
});
|
||||
}, [domains, keyword, zoneMap]);
|
||||
}, [availableDomains, keyword, zoneMap]);
|
||||
|
||||
const toggle = (domain: ZoneDomainItem) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const boundElsewhere =
|
||||
if (
|
||||
domain.proxy_route_id != null &&
|
||||
domain.proxy_route_id !== currentRouteId;
|
||||
if (boundElsewhere) {
|
||||
domain.proxy_route_id !== currentRouteId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,12 +92,26 @@ export function ZoneDomainSelector({
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索域名或 Zone…"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索域名或 Zone…"
|
||||
disabled={disabled}
|
||||
className="sm:flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
快捷新增域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
@@ -88,8 +121,10 @@ export function ZoneDomainSelector({
|
||||
<Link href="/websites" className="text-primary underline-offset-4 hover:underline">
|
||||
网站管理
|
||||
</Link>{' '}
|
||||
中添加 FQDN。
|
||||
中添加 FQDN,或使用「快捷新增域名」。
|
||||
</>
|
||||
) : availableDomains.length === 0 ? (
|
||||
'没有可绑定的域名(其余域名已绑定其他路由)。可用「快捷新增域名」创建。'
|
||||
) : (
|
||||
'没有匹配的域名'
|
||||
)}
|
||||
@@ -97,9 +132,6 @@ export function ZoneDomainSelector({
|
||||
) : (
|
||||
<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);
|
||||
|
||||
@@ -108,13 +140,12 @@ export function ZoneDomainSelector({
|
||||
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',
|
||||
checked && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled || boundElsewhere}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => toggle(domain)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
@@ -130,11 +161,6 @@ export function ZoneDomainSelector({
|
||||
无证书
|
||||
</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 ? (
|
||||
@@ -156,6 +182,19 @@ export function ZoneDomainSelector({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QuickCreateZoneDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
zones={zones}
|
||||
onCreated={async (domain) => {
|
||||
// Auto-select the new domain for this route binding.
|
||||
if (!value.includes(domain.id)) {
|
||||
onChange([...value, domain.id].sort((a, b) => a - b));
|
||||
}
|
||||
await onDomainCreated?.(domain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
proxy_route_id: route.id,
|
||||
domain: bound.domain,
|
||||
cert_id: bound.cert_id,
|
||||
remark: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
@@ -177,10 +176,14 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
zones={zonesQuery.data ?? []}
|
||||
currentRouteId={route.id}
|
||||
disabled={domainsQuery.isLoading}
|
||||
onDomainCreated={async () => {
|
||||
await domainsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
仅选择绑定关系;证书与 FQDN 请到对应 Zone 详情页管理。
|
||||
从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建 FQDN
|
||||
并自动勾选。
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -35,7 +35,6 @@ const reverseProxySchema = z
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
custom_headers_text: z.string(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
@@ -137,7 +136,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +150,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
@@ -210,7 +207,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
origin_host: values.origin_host.trim(),
|
||||
upstreams,
|
||||
custom_headers: headers,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: values.upstream_type,
|
||||
tunnel_node_id:
|
||||
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||
@@ -417,20 +413,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="例如:多活回源,优先使用上海入口" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SectionShell>
|
||||
|
||||
@@ -72,7 +72,6 @@ export function ProxyRoutesPageClient() {
|
||||
getRoutePrimaryDomain(route),
|
||||
...getRouteDomainNames(route),
|
||||
route.origin_url,
|
||||
route.remark,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
@@ -81,7 +81,6 @@ export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
|
||||
region_blacklist: [],
|
||||
pow_enabled: false,
|
||||
pow_config: defaultPowConfig,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultRuleModalState: RuleModalState = {
|
||||
@@ -147,7 +146,6 @@ export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPay
|
||||
region_blacklist: group.region_blacklist ?? [],
|
||||
pow_enabled: group.pow_enabled ?? false,
|
||||
pow_config: group.pow_config ?? defaultPowConfig,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,7 +291,6 @@ export function buildIPGroupPayloadFromGroup(
|
||||
subscription_format: group.subscription_format ?? 'text',
|
||||
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ const ipGroupSchema = z
|
||||
subscription_format: z.enum(['text', 'json']),
|
||||
subscription_mapping_rule: z.string(),
|
||||
sync_interval_minutes: z.number().int().min(5),
|
||||
remark: z.string().max(500),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.type === 'subscription' && !value.subscription_url.trim()) {
|
||||
@@ -75,7 +74,6 @@ const defaultValues: IPGroupFormValues = {
|
||||
subscription_format: 'text',
|
||||
subscription_mapping_rule: '',
|
||||
sync_interval_minutes: 1440,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
|
||||
@@ -90,7 +88,6 @@ function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
|
||||
subscription_format: group.subscription_format ?? 'text',
|
||||
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +106,6 @@ function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
|
||||
subscription_format: values.subscription_format,
|
||||
subscription_mapping_rule: values.subscription_mapping_rule.trim(),
|
||||
sync_interval_minutes: values.sync_interval_minutes,
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,19 +243,6 @@ export function IPGroupDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === 'subscription' ? (
|
||||
|
||||
@@ -43,7 +43,6 @@ const ruleGroupSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
block_status_code: z.number().int().min(400).max(599),
|
||||
block_response_body: z.string(),
|
||||
remark: z.string().max(500, '备注不能超过 500 个字符'),
|
||||
pow_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -82,7 +81,6 @@ export function RuleGroupSheet({
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
remark: '',
|
||||
pow_enabled: false,
|
||||
},
|
||||
});
|
||||
@@ -96,7 +94,6 @@ export function RuleGroupSheet({
|
||||
enabled: draft.enabled,
|
||||
block_status_code: draft.block_status_code,
|
||||
block_response_body: draft.block_response_body,
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleEntryOpen(false);
|
||||
@@ -148,7 +145,6 @@ export function RuleGroupSheet({
|
||||
enabled: values.enabled,
|
||||
block_status_code: values.block_status_code,
|
||||
block_response_body: values.block_response_body,
|
||||
remark: values.remark,
|
||||
pow_enabled: values.pow_enabled,
|
||||
};
|
||||
try {
|
||||
@@ -220,19 +216,6 @@ export function RuleGroupSheet({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,152 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import {TlsCertificateService, ZoneDomainService} from '@/lib/services/openflare';
|
||||
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入完整域名')
|
||||
.refine((value) => !value.includes('*.'), 'Zone 域名不支持通配符'),
|
||||
cert_id: z.string(),
|
||||
remark: z.string().max(255),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
import {QuickCreateZoneDomainDialog} from '../../components/quick-create-zone-domain-dialog';
|
||||
|
||||
/** Zone 详情页添加域名(固定 Zone,支持简写 / @ / 完整 FQDN)。 */
|
||||
export function ZoneDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zoneId,
|
||||
zoneRoot,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
zoneId: number;
|
||||
zoneRoot: string;
|
||||
onSaved(): Promise<unknown> | void;
|
||||
}) {
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {domain: '', cert_id: '', remark: ''},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({domain: '', cert_id: '', remark: ''});
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Values) =>
|
||||
ZoneDomainService.create(zoneId, {
|
||||
domain: values.domain.toLowerCase(),
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
remark: values.remark.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('域名已添加');
|
||||
await Promise.all([
|
||||
onSaved(),
|
||||
queryClient.invalidateQueries({queryKey: ['openflare', 'zones']}),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 Zone 域名</DialogTitle>
|
||||
<DialogDescription>
|
||||
填写明确 FQDN;通配符仅可存在于所选证书的 SAN 中。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id="zone-domain-create"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fqdn">完整域名</Label>
|
||||
<Input id="fqdn" placeholder="api.arctel.de" {...form.register('domain')} />
|
||||
{form.formState.errors.domain ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>证书</Label>
|
||||
<Select
|
||||
value={form.watch('cert_id') || '__none'}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === '__none' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不绑定证书</SelectItem>
|
||||
{(certificatesQuery.data ?? []).map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{certificate.name} · {certificate.primary_domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain-remark">备注</Label>
|
||||
<Textarea id="domain-remark" rows={2} {...form.register('remark')} />
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="zone-domain-create"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
添加域名
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<QuickCreateZoneDomainDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
fixedZoneId={zoneId}
|
||||
fixedZoneRoot={zoneRoot}
|
||||
onCreated={async () => {
|
||||
await onSaved();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {ZoneDomainDialog} from './zone-domain-dialog';
|
||||
|
||||
export function ZoneDomainsTable({
|
||||
zoneId,
|
||||
zoneRoot,
|
||||
domains,
|
||||
certificates,
|
||||
routes,
|
||||
@@ -39,6 +40,7 @@ export function ZoneDomainsTable({
|
||||
onChanged,
|
||||
}: {
|
||||
zoneId: number;
|
||||
zoneRoot: string;
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
routes: ProxyRouteItem[];
|
||||
@@ -98,9 +100,6 @@ export function ZoneDomainsTable({
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[200px]">
|
||||
上游
|
||||
</TableHead>
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[120px]">
|
||||
备注
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
@@ -160,9 +159,6 @@ export function ZoneDomainsTable({
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[160px] truncate py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{domain.remark || '—'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -220,6 +216,7 @@ export function ZoneDomainsTable({
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
zoneId={zoneId}
|
||||
zoneRoot={zoneRoot}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,15 +11,78 @@ import {Button} from '@/components/ui/button'
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {Textarea} from '@/components/ui/textarea'
|
||||
import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({domain: z.string().trim().min(1, '请输入 Zone 根域').refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'), remark: z.string().max(255)})
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入 Zone 根域')
|
||||
.refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function ZoneEditorDialog({open, onOpenChange, zone}: {open: boolean; onOpenChange(open: boolean): void; zone?: ZoneItem | null}) {
|
||||
const queryClient = useQueryClient(); const form = useForm<Values>({resolver: zodResolver(schema), defaultValues: {domain: '', remark: ''}})
|
||||
useEffect(() => { if (open) form.reset({domain: zone?.domain ?? '', remark: zone?.remark ?? ''}) }, [form, open, zone])
|
||||
const mutation = useMutation({mutationFn: (values: Values) => zone ? ZoneService.update(zone.id, values) : ZoneService.create(values), onSuccess: async () => { toast.success(zone ? 'Zone 已更新' : 'Zone 已创建'); await queryClient.invalidateQueries({queryKey: zoneQueryKey}); onOpenChange(false) }, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle><DialogDescription>Zone 仅接受可注册根域,例如 arctel.de。</DialogDescription></DialogHeader><form id="zone-editor" className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate({domain: values.domain.toLowerCase(), remark: values.remark.trim()}))}><div className="space-y-1.5"><Label htmlFor="zone-domain">根域</Label><Input id="zone-domain" placeholder="arctel.de" {...form.register('domain')} />{form.formState.errors.domain && <p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>}</div><div className="space-y-1.5"><Label htmlFor="zone-remark">备注</Label><Textarea id="zone-remark" rows={2} {...form.register('remark')} /></div></form><DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button form="zone-editor" type="submit" disabled={mutation.isPending}>{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}{zone ? '保存修改' : '新增 Zone'}</Button></DialogFooter></DialogContent></Dialog>
|
||||
export function ZoneEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zone,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange(open: boolean): void
|
||||
zone?: ZoneItem | null
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {domain: ''},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (open) form.reset({domain: zone?.domain ?? ''})
|
||||
}, [form, open, zone])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Values) =>
|
||||
zone
|
||||
? ZoneService.update(zone.id, {domain: values.domain.toLowerCase()})
|
||||
: ZoneService.create({domain: values.domain.toLowerCase()}),
|
||||
onSuccess: async () => {
|
||||
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建')
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey})
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
})
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle>
|
||||
<DialogDescription>Zone 仅接受可注册根域,例如 arctel.de。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id="zone-editor"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
mutation.mutate({domain: values.domain.toLowerCase()}),
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zone-domain">根域</Label>
|
||||
<Input id="zone-domain" placeholder="arctel.de" {...form.register('domain')} />
|
||||
{form.formState.errors.domain && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button form="zone-editor" type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{zone ? '保存修改' : '新增 Zone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">域名</CardTitle><CardDescription>显式 FQDN</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{overview.domains.length}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">关联路由</CardTitle><CardDescription>已绑定的不同规则</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{boundRoutes}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">已配证书</CardTitle><CardDescription>域名级 TLS 绑定</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{certificates}</CardContent></Card>
|
||||
<Card className="shadow-none md:col-span-3"><CardHeader className="pb-2"><CardTitle className="text-sm">Zone 信息</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm sm:grid-cols-2"><div><p className="text-xs text-muted-foreground">根域</p><p className="mt-1 font-medium">{overview.zone.domain}</p></div><div><p className="text-xs text-muted-foreground">创建时间</p><p className="mt-1">{formatDateTime(overview.zone.created_at)}</p></div><div className="sm:col-span-2"><p className="text-xs text-muted-foreground">备注</p><p className="mt-1">{overview.zone.remark || '暂无备注'}</p></div></CardContent></Card>
|
||||
<Card className="shadow-none md:col-span-3"><CardHeader className="pb-2"><CardTitle className="text-sm">Zone 信息</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm sm:grid-cols-2"><div><p className="text-xs text-muted-foreground">根域</p><p className="mt-1 font-medium">{overview.zone.domain}</p></div><div><p className="text-xs text-muted-foreground">创建时间</p><p className="mt-1">{formatDateTime(overview.zone.created_at)}</p></div></CardContent></Card>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ export function ZonePageClient({zoneId}: {zoneId: number}) {
|
||||
<TabsContent value="domains">
|
||||
<ZoneDomainsTable
|
||||
zoneId={zoneId}
|
||||
zoneRoot={overview.zone.domain}
|
||||
domains={overview.domains}
|
||||
certificates={certificates}
|
||||
routes={routes}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {
|
||||
TlsCertificateService,
|
||||
ZoneDomainService,
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneDomainItem,
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {previewZoneDomainInput, resolveZoneDomainInput} from './resolve-zone-domain-input';
|
||||
|
||||
const schema = z.object({
|
||||
zone_id: z.string().min(1, '请选择 Zone'),
|
||||
domain_input: z.string().trim().min(1, '请输入域名'),
|
||||
cert_id: z.string(),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
export function QuickCreateZoneDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fixedZoneId,
|
||||
fixedZoneRoot,
|
||||
zones: zonesProp,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
/** When set, Zone 选择器隐藏 */
|
||||
fixedZoneId?: number;
|
||||
fixedZoneRoot?: string;
|
||||
zones?: ZoneItem[];
|
||||
onCreated(domain: ZoneDomainItem): void | Promise<void>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
enabled: open && !fixedZoneId && !zonesProp,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const zones = useMemo(
|
||||
() => zonesProp ?? zonesQuery.data ?? [],
|
||||
[zonesProp, zonesQuery.data],
|
||||
);
|
||||
const fixedZone = useMemo(() => {
|
||||
if (!fixedZoneId) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
zones.find((zone) => zone.id === fixedZoneId) ??
|
||||
(fixedZoneRoot
|
||||
? ({id: fixedZoneId, domain: fixedZoneRoot, created_at: '', updated_at: ''} as ZoneItem)
|
||||
: undefined)
|
||||
);
|
||||
}, [fixedZoneId, fixedZoneRoot, zones]);
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
form.reset({
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
});
|
||||
}, [fixedZoneId, form, open]);
|
||||
|
||||
const watchedZoneId = form.watch('zone_id');
|
||||
const watchedInput = form.watch('domain_input');
|
||||
const selectedZone = useMemo(() => {
|
||||
if (fixedZone) {
|
||||
return fixedZone;
|
||||
}
|
||||
const id = Number(watchedZoneId);
|
||||
return zones.find((zone) => zone.id === id);
|
||||
}, [fixedZone, watchedZoneId, zones]);
|
||||
|
||||
const preview = selectedZone
|
||||
? previewZoneDomainInput(watchedInput, selectedZone.domain)
|
||||
: '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const zoneId = Number(values.zone_id);
|
||||
const zone =
|
||||
fixedZone ?? zones.find((item) => item.id === zoneId);
|
||||
if (!zone) {
|
||||
throw new Error('请选择 Zone');
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(values.domain_input, zone.domain);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
throw new Error(resolved.error || '域名格式不合法');
|
||||
}
|
||||
return ZoneDomainService.create(zone.id, {
|
||||
domain: resolved.domain,
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
toast.success('域名已添加', {description: domain.domain});
|
||||
await Promise.all([
|
||||
onCreated(domain),
|
||||
queryClient.invalidateQueries({queryKey: zoneQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: [...zoneQueryKey, 'all-domains']}),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '添加失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>快捷新增域名</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="quick-create-zone-domain"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
if (!selectedZone) {
|
||||
form.setError('zone_id', {message: '请选择 Zone'});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(
|
||||
values.domain_input,
|
||||
selectedZone.domain,
|
||||
);
|
||||
if (resolved.error) {
|
||||
form.setError('domain_input', {message: resolved.error});
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{!fixedZoneId ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Zone</Label>
|
||||
<Select
|
||||
value={form.watch('zone_id') || undefined}
|
||||
onValueChange={(value) => form.setValue('zone_id', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择注册根域" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zones.map((zone) => (
|
||||
<SelectItem key={zone.id} value={String(zone.id)}>
|
||||
{zone.domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.zone_id ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.zone_id.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
Zone:
|
||||
<span className="ml-1 font-medium">
|
||||
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain-input">域名</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
placeholder={
|
||||
selectedZone
|
||||
? `api 或 @ 或 api.${selectedZone.domain}`
|
||||
: 'api / @ / 完整域名'
|
||||
}
|
||||
{...form.register('domain_input')}
|
||||
/>
|
||||
{preview ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
将创建:
|
||||
<code className="ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{preview}
|
||||
</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
示例:输入 <code className="font-mono">api</code> →{' '}
|
||||
<code className="font-mono">api.zone.com</code>;
|
||||
<code className="font-mono">@</code> → 根域本身
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.domain_input ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain_input.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>证书(可选)</Label>
|
||||
<Select
|
||||
value={form.watch('cert_id') || '__none'}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === '__none' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不绑定证书</SelectItem>
|
||||
{(certificatesQuery.data ?? []).map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{certificate.name} · {certificate.primary_domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="quick-create-zone-domain"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
添加域名
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Resolve user input into a full FQDN under a Zone root.
|
||||
*
|
||||
* - `@` → apex (zone root itself), e.g. `example.com`
|
||||
* - single label `name` → `name.example.com`
|
||||
* - full FQDN must equal the root or end with `.root`
|
||||
*/
|
||||
export function resolveZoneDomainInput(
|
||||
rawInput: string,
|
||||
zoneRoot: string,
|
||||
): {domain: string; error?: string} {
|
||||
const input = rawInput.trim().toLowerCase();
|
||||
const root = zoneRoot.trim().toLowerCase();
|
||||
|
||||
if (!root) {
|
||||
return {domain: '', error: '请先选择 Zone'};
|
||||
}
|
||||
if (!input) {
|
||||
return {domain: '', error: '请输入域名'};
|
||||
}
|
||||
if (input.includes('*')) {
|
||||
return {domain: '', error: 'Zone 域名不支持通配符'};
|
||||
}
|
||||
if (input.includes('://') || input.includes('/') || input.includes('?') || input.includes('#')) {
|
||||
return {domain: '', error: '域名格式不合法'};
|
||||
}
|
||||
|
||||
if (input === '@') {
|
||||
return {domain: root};
|
||||
}
|
||||
|
||||
// Short label: no dots → prefix under zone root
|
||||
if (!input.includes('.')) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
|
||||
return {domain: '', error: '子域名称格式不合法'};
|
||||
}
|
||||
return {domain: `${input}.${root}`};
|
||||
}
|
||||
|
||||
// Full FQDN
|
||||
if (input === root || input.endsWith(`.${root}`)) {
|
||||
return {domain: input};
|
||||
}
|
||||
|
||||
return {domain: '', error: `域名必须属于 Zone ${root}`};
|
||||
}
|
||||
|
||||
/** Live preview string for the input helper text. */
|
||||
export function previewZoneDomainInput(rawInput: string, zoneRoot: string): string {
|
||||
const resolved = resolveZoneDomainInput(rawInput, zoneRoot);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
return '';
|
||||
}
|
||||
return resolved.domain;
|
||||
}
|
||||
@@ -94,7 +94,6 @@ export default function WebsitesPage() {
|
||||
<h2 className="truncate text-sm font-semibold">{zone.domain}</h2>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{zone.domain_count ?? 0} 个域名
|
||||
{zone.remark ? ` · ${zone.remark}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -261,7 +261,6 @@ export interface ProxyRouteItem {
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username: string;
|
||||
basic_auth_password: string;
|
||||
remark: string;
|
||||
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
@@ -296,7 +295,6 @@ export interface ProxyRouteMutationPayload {
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username?: string;
|
||||
basic_auth_password?: string;
|
||||
remark: string;
|
||||
upstream_type?: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
@@ -708,7 +706,6 @@ export interface WAFRuleGroup {
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
applied_site_ids: number[];
|
||||
applied_site_count: number;
|
||||
created_at: string;
|
||||
@@ -730,7 +727,6 @@ export interface WAFRuleGroupPayload {
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFSiteRuleGroups {
|
||||
@@ -765,7 +761,6 @@ export interface WAFIPGroup {
|
||||
next_sync_at?: string;
|
||||
last_sync_status: string;
|
||||
last_sync_message: string;
|
||||
remark: string;
|
||||
referenced_by_rule_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -781,7 +776,6 @@ export interface WAFIPGroupPayload {
|
||||
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||
subscription_mapping_rule: string;
|
||||
sync_interval_minutes: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupSyncResult {
|
||||
@@ -964,7 +958,6 @@ export interface DashboardOverviewCompact {
|
||||
export interface ZoneItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
remark: string;
|
||||
/** Present on list API; may be omitted on nested zone objects. */
|
||||
domain_count?: number;
|
||||
created_at: string;
|
||||
@@ -973,7 +966,6 @@ export interface ZoneItem {
|
||||
|
||||
export interface ZoneMutationPayload {
|
||||
domain: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ZoneDomainItem {
|
||||
@@ -982,7 +974,6 @@ export interface ZoneDomainItem {
|
||||
proxy_route_id: number | null;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -990,7 +981,6 @@ export interface ZoneDomainItem {
|
||||
export interface ZoneDomainMutationPayload {
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ZoneOverview {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {resolveZoneDomainInput} from '@/app/(main)/websites/components/resolve-zone-domain-input';
|
||||
|
||||
describe('resolveZoneDomainInput', () => {
|
||||
it('maps short label and apex @ under zone root', () => {
|
||||
expect(resolveZoneDomainInput('api', 'example.com')).toEqual({
|
||||
domain: 'api.example.com',
|
||||
});
|
||||
expect(resolveZoneDomainInput('@', 'example.com')).toEqual({
|
||||
domain: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts full FQDN under the zone', () => {
|
||||
expect(resolveZoneDomainInput('www.api.example.com', 'example.com')).toEqual({
|
||||
domain: 'www.api.example.com',
|
||||
});
|
||||
expect(resolveZoneDomainInput('example.com', 'example.com')).toEqual({
|
||||
domain: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects foreign domains and wildcards', () => {
|
||||
expect(resolveZoneDomainInput('evil.com', 'example.com').error).toBeTruthy();
|
||||
expect(resolveZoneDomainInput('*.example.com', 'example.com').error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,6 @@ describe('WebsitesPage', () => {
|
||||
{
|
||||
id: 42,
|
||||
domain: 'arctel.de',
|
||||
remark: '生产站点',
|
||||
domain_count: 3,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
@@ -39,7 +38,6 @@ describe('WebsitesPage', () => {
|
||||
{
|
||||
id: 43,
|
||||
domain: 'example.com',
|
||||
remark: '',
|
||||
domain_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
@@ -12,7 +13,7 @@ vi.mock('next/link', () => ({
|
||||
}));
|
||||
|
||||
const zones: ZoneItem[] = [
|
||||
{id: 1, domain: 'arctel.de', remark: '', created_at: '', updated_at: ''},
|
||||
{id: 1, domain: 'arctel.de', created_at: '', updated_at: ''},
|
||||
];
|
||||
|
||||
const domains: ZoneDomainItem[] = [
|
||||
@@ -22,7 +23,6 @@ const domains: ZoneDomainItem[] = [
|
||||
proxy_route_id: null,
|
||||
domain: 'api.arctel.de',
|
||||
cert_id: 9,
|
||||
remark: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
@@ -32,18 +32,22 @@ const domains: ZoneDomainItem[] = [
|
||||
proxy_route_id: 99,
|
||||
domain: 'bound.arctel.de',
|
||||
cert_id: null,
|
||||
remark: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
|
||||
function renderSelector(ui: React.ReactElement) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ZoneDomainSelector', () => {
|
||||
it('renders domains and toggles selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[7]}
|
||||
onChange={onChange}
|
||||
@@ -53,29 +57,41 @@ describe('ZoneDomainSelector', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('api.arctel.de')).toBeVisible();
|
||||
expect(screen.getByText('bound.arctel.de')).toBeVisible();
|
||||
expect(screen.getByText('路由 #99')).toBeVisible();
|
||||
// Bound to another route — hidden by default
|
||||
expect(screen.queryByText('bound.arctel.de')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', {name: /快捷新增域名/})).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(
|
||||
it('hides domains bound to another route', () => {
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
onChange={vi.fn()}
|
||||
domains={domains}
|
||||
zones={zones}
|
||||
currentRouteId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('bound.arctel.de'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('api.arctel.de')).toBeVisible();
|
||||
expect(screen.queryByText('bound.arctel.de')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows domains already bound to the current route', () => {
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[8]}
|
||||
onChange={vi.fn()}
|
||||
domains={domains}
|
||||
zones={zones}
|
||||
currentRouteId={99}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('bound.arctel.de')).toBeVisible();
|
||||
expect(screen.getByText('api.arctel.de')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('ZonePageClient', () => {
|
||||
|
||||
it('loads the overview by stable ID and exposes all tabs including empty domains', async () => {
|
||||
vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({
|
||||
zone: {id: 42, domain: 'arctel.de', remark: '', created_at: '', updated_at: ''},
|
||||
zone: {id: 42, domain: 'arctel.de', created_at: '', updated_at: ''},
|
||||
domains: [],
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user