mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
[功能] 更新代理路由页面,集成新的输入和选择组件,优化域名选择逻辑
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Input, Textarea } from '@heroui/input';
|
||||||
|
import { Select, SelectItem, SelectSection } from '@heroui/select';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -46,9 +48,6 @@ import {
|
|||||||
DangerButton,
|
DangerButton,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
ResourceInput,
|
|
||||||
ResourceSelect,
|
|
||||||
ResourceTextarea,
|
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
@@ -275,6 +274,31 @@ type SectionIconProps = {
|
|||||||
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
|
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
|
||||||
const PANEL_CLASS_NAME =
|
const PANEL_CLASS_NAME =
|
||||||
'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]';
|
'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]';
|
||||||
|
const HERO_INPUT_CLASS_NAMES = {
|
||||||
|
base: 'w-full',
|
||||||
|
mainWrapper: 'w-full',
|
||||||
|
inputWrapper:
|
||||||
|
'min-h-10 rounded-xl border border-[var(--border-default)] shadow-none',
|
||||||
|
input: 'text-sm',
|
||||||
|
} as const;
|
||||||
|
const HERO_TEXTAREA_CLASS_NAMES = {
|
||||||
|
base: 'w-full',
|
||||||
|
mainWrapper: 'w-full',
|
||||||
|
inputWrapper:
|
||||||
|
'min-h-28 rounded-xl border border-[var(--border-default)] shadow-none',
|
||||||
|
input: 'text-sm',
|
||||||
|
} as const;
|
||||||
|
const HERO_SELECT_CLASS_NAMES = {
|
||||||
|
base: 'w-full',
|
||||||
|
trigger: cn(
|
||||||
|
INPUT_CLASS_NAME,
|
||||||
|
'min-h-10 rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none data-[hover=true]:bg-[var(--surface-elevated)] data-[open=true]:border-[var(--border-strong)]',
|
||||||
|
),
|
||||||
|
value: 'text-sm text-[var(--foreground-primary)]',
|
||||||
|
popoverContent:
|
||||||
|
'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]',
|
||||||
|
listboxWrapper: 'p-1',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const defaultValues: ProxyRouteFormValues = {
|
const defaultValues: ProxyRouteFormValues = {
|
||||||
managed_domain_id: '',
|
managed_domain_id: '',
|
||||||
@@ -645,21 +669,17 @@ function toFormValues(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primaryOrigin = parseOriginUrl(route.origin_url);
|
const primaryOrigin = parseOriginUrl(route.origin_url);
|
||||||
const originRows: OriginRowFormValue[] = [
|
const normalizedOrigins = Array.from(
|
||||||
{
|
new Set([route.origin_url, ...upstreams].filter(Boolean)),
|
||||||
scheme: primaryOrigin.scheme,
|
);
|
||||||
address: primaryOrigin.address,
|
const originRows: OriginRowFormValue[] = normalizedOrigins.map((origin) => {
|
||||||
port: primaryOrigin.port,
|
const parsed = parseOriginUrl(origin);
|
||||||
},
|
return {
|
||||||
...upstreams.map((upstream) => {
|
scheme: parsed.scheme,
|
||||||
const parsed = parseOriginUrl(upstream);
|
address: parsed.address,
|
||||||
return {
|
port: parsed.port,
|
||||||
scheme: parsed.scheme,
|
};
|
||||||
address: parsed.address,
|
});
|
||||||
port: parsed.port,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
managed_domain_id: managedDomainMatch.managedDomainId,
|
managed_domain_id: managedDomainMatch.managedDomainId,
|
||||||
@@ -752,58 +772,68 @@ function ProxyRuleSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchableManagedDomainField({
|
type SearchOption = {
|
||||||
value,
|
key: string;
|
||||||
domains,
|
label: string;
|
||||||
|
keywords?: string[];
|
||||||
|
selected?: boolean;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
trailing?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SearchableSelectInput({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
error,
|
error,
|
||||||
|
showFieldMeta = true,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
emptyText,
|
||||||
|
options,
|
||||||
|
onValueChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
label: string;
|
||||||
domains: ManagedDomainItem[];
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
onSelect: (value: string) => void;
|
showFieldMeta?: boolean;
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
emptyText: string;
|
||||||
|
options: SearchOption[];
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
onSelect: (option: SearchOption) => void;
|
||||||
}) {
|
}) {
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const selectedDomain =
|
const filteredOptions = useMemo(() => {
|
||||||
domains.find((item) => item.domain === value)?.domain ?? value;
|
const keyword = value.trim().toLowerCase();
|
||||||
const filteredDomains = useMemo(() => {
|
|
||||||
const keyword = inputValue.trim().toLowerCase();
|
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
return domains;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
return domains.filter((item) =>
|
return options.filter((option) => {
|
||||||
item.domain.toLowerCase().includes(keyword),
|
const haystacks = [option.label, ...(option.keywords ?? [])];
|
||||||
);
|
return haystacks.some((item) => item.toLowerCase().includes(keyword));
|
||||||
}, [domains, inputValue]);
|
});
|
||||||
const highlightedDomain =
|
}, [options, value]);
|
||||||
filteredDomains[Math.min(highlightedIndex, Math.max(filteredDomains.length - 1, 0))] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
const highlightedOption =
|
||||||
if (value) {
|
filteredOptions[
|
||||||
setInputValue(value);
|
Math.min(highlightedIndex, Math.max(filteredOptions.length - 1, 0))
|
||||||
return;
|
] ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
setInputValue('');
|
|
||||||
}
|
|
||||||
}, [isOpen, value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHighlightedIndex(0);
|
setHighlightedIndex(0);
|
||||||
}, [inputValue]);
|
}, [value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlightedIndex > filteredDomains.length - 1) {
|
if (highlightedIndex > filteredOptions.length - 1) {
|
||||||
setHighlightedIndex(0);
|
setHighlightedIndex(0);
|
||||||
}
|
}
|
||||||
}, [filteredDomains.length, highlightedIndex]);
|
}, [filteredOptions.length, highlightedIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -822,126 +852,302 @@ function SearchableManagedDomainField({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-[var(--foreground-muted)]" />
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||||
|
INPUT_CLASS_NAME,
|
||||||
|
'pl-9 pr-10',
|
||||||
|
)}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
onValueChange(event.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'ArrowDown' && filteredOptions.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex((current) =>
|
||||||
|
current >= filteredOptions.length - 1 ? 0 : current + 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp' && filteredOptions.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex((current) =>
|
||||||
|
current <= 0 ? filteredOptions.length - 1 : current - 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && highlightedOption) {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect(highlightedOption);
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
aria-label="切换候选列表"
|
||||||
|
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-[var(--foreground-muted)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
|
>
|
||||||
|
<ChevronIcon
|
||||||
|
expanded={isOpen}
|
||||||
|
className="h-4 w-4 text-[var(--foreground-muted)]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-3 shadow-[var(--shadow-soft)]">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 px-1 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
<span>
|
||||||
|
{highlightedOption
|
||||||
|
? `按 Enter 选择 ${highlightedOption.label}`
|
||||||
|
: '继续输入以匹配候选项'}
|
||||||
|
</span>
|
||||||
|
<span>{`${filteredOptions.length} 个结果`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(option);
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]',
|
||||||
|
option.selected
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||||
|
: index === highlightedIndex
|
||||||
|
? 'bg-[var(--control-background)] text-[var(--foreground-primary)]'
|
||||||
|
: 'text-[var(--foreground-secondary)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{option.content ?? option.label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{index === highlightedIndex ? (
|
||||||
|
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
|
||||||
|
Enter
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{option.trailing}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="rounded-xl border border-dashed border-[var(--border-default)] px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||||
|
{emptyText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showFieldMeta) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourceField
|
<ResourceField label={label} hint={hint} error={error}>
|
||||||
|
{content}
|
||||||
|
</ResourceField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchableManagedDomainField({
|
||||||
|
value,
|
||||||
|
domains,
|
||||||
|
error,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
domains: ManagedDomainItem[];
|
||||||
|
error?: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setInputValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputValue('');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchableSelectInput
|
||||||
label="Select Target Domain"
|
label="Select Target Domain"
|
||||||
hint="直接输入域名关键字进行本地模糊匹配,按 Enter 选择当前匹配项。"
|
hint="直接输入域名关键字进行本地模糊匹配,按 Enter 选择当前匹配项。"
|
||||||
error={error}
|
error={error}
|
||||||
>
|
value={inputValue}
|
||||||
<div ref={containerRef} className="relative">
|
placeholder="输入并搜索目标域名"
|
||||||
<div className="relative">
|
emptyText="未发现匹配域名"
|
||||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground-muted)]" />
|
options={domains.map((domain) => ({
|
||||||
<ResourceInput
|
key: String(domain.id),
|
||||||
value={inputValue}
|
label: domain.domain,
|
||||||
placeholder="输入并搜索目标域名"
|
keywords: [domain.domain],
|
||||||
className={cn(INPUT_CLASS_NAME, 'pl-9 pr-10')}
|
selected: value === domain.domain,
|
||||||
onFocus={() => setIsOpen(true)}
|
trailing: domain.cert_id ? (
|
||||||
onChange={(event) => {
|
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
|
||||||
setInputValue(event.target.value);
|
TLS
|
||||||
setIsOpen(true);
|
</span>
|
||||||
}}
|
) : null,
|
||||||
onKeyDown={(event) => {
|
}))}
|
||||||
if (event.key === 'ArrowDown' && filteredDomains.length > 0) {
|
onValueChange={setInputValue}
|
||||||
event.preventDefault();
|
onSelect={(option) => {
|
||||||
setIsOpen(true);
|
onSelect(option.label);
|
||||||
setHighlightedIndex((current) =>
|
setInputValue(option.label);
|
||||||
current >= filteredDomains.length - 1 ? 0 : current + 1,
|
}}
|
||||||
);
|
/>
|
||||||
return;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowUp' && filteredDomains.length > 0) {
|
function SearchableOriginAddressField({
|
||||||
event.preventDefault();
|
value,
|
||||||
setIsOpen(true);
|
origins,
|
||||||
setHighlightedIndex((current) =>
|
onValueChange,
|
||||||
current <= 0 ? filteredDomains.length - 1 : current - 1,
|
}: {
|
||||||
);
|
value: string;
|
||||||
return;
|
origins: OriginItem[];
|
||||||
}
|
onValueChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
if (event.key === 'Enter' && highlightedDomain) {
|
return (
|
||||||
event.preventDefault();
|
<SearchableSelectInput
|
||||||
onSelect(highlightedDomain.domain);
|
label="源站地址"
|
||||||
setInputValue(highlightedDomain.domain);
|
showFieldMeta={false}
|
||||||
setIsOpen(false);
|
value={value}
|
||||||
setHighlightedIndex(0);
|
placeholder="192.168.1.45"
|
||||||
return;
|
emptyText="未发现匹配资产,请手动输入"
|
||||||
}
|
options={origins.map((origin) => ({
|
||||||
|
key: String(origin.id),
|
||||||
if (event.key === 'Escape') {
|
label: origin.address,
|
||||||
setIsOpen(false);
|
keywords: [origin.address, origin.name],
|
||||||
}
|
selected: value.trim().toLowerCase() === origin.address.toLowerCase(),
|
||||||
}}
|
content: (
|
||||||
/>
|
<span className="text-[var(--foreground-primary)]">
|
||||||
<button
|
{origin.address}
|
||||||
type="button"
|
{origin.name ? (
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
<span className="ml-1 text-[var(--foreground-muted)]">
|
||||||
aria-label="切换域名候选列表"
|
({origin.name})
|
||||||
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-[var(--foreground-muted)] transition hover:bg-[var(--control-background-hover)]"
|
|
||||||
>
|
|
||||||
<ChevronIcon
|
|
||||||
expanded={isOpen}
|
|
||||||
className="h-4 w-4 text-[var(--foreground-muted)]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-3 shadow-[var(--shadow-soft)]">
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 px-1 text-xs text-[var(--foreground-secondary)]">
|
|
||||||
<span>
|
|
||||||
{highlightedDomain
|
|
||||||
? `按 Enter 选择 ${highlightedDomain.domain}`
|
|
||||||
: '继续输入以匹配目标域名'}
|
|
||||||
</span>
|
</span>
|
||||||
<span>{`${filteredDomains.length} 个结果`}</span>
|
) : null}
|
||||||
</div>
|
</span>
|
||||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
),
|
||||||
{filteredDomains.length > 0 ? (
|
trailing: isLocalOriginAddress(origin.address) ? (
|
||||||
filteredDomains.map((domain, index) => (
|
<span className="rounded-md bg-[var(--accent-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-secondary)]">
|
||||||
<button
|
Local
|
||||||
key={domain.id}
|
</span>
|
||||||
type="button"
|
) : null,
|
||||||
onClick={() => {
|
}))}
|
||||||
onSelect(domain.domain);
|
onValueChange={onValueChange}
|
||||||
setInputValue(domain.domain);
|
onSelect={(option) => onValueChange(option.label)}
|
||||||
setIsOpen(false);
|
/>
|
||||||
setHighlightedIndex(0);
|
);
|
||||||
}}
|
}
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]',
|
function HeroTextInput(
|
||||||
value === domain.domain
|
props: React.ComponentProps<typeof Input> & { className?: string },
|
||||||
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
) {
|
||||||
: index === highlightedIndex
|
const { className, ...restProps } = props;
|
||||||
? 'bg-[var(--control-background)] text-[var(--foreground-primary)]'
|
|
||||||
: 'text-[var(--foreground-secondary)]',
|
return (
|
||||||
)}
|
<Input
|
||||||
>
|
variant="bordered"
|
||||||
<span>{domain.domain}</span>
|
size="sm"
|
||||||
<div className="flex items-center gap-2">
|
radius="lg"
|
||||||
{index === highlightedIndex ? (
|
classNames={{
|
||||||
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
|
...HERO_INPUT_CLASS_NAMES,
|
||||||
Enter
|
base: cn(HERO_INPUT_CLASS_NAMES.base, className),
|
||||||
</span>
|
}}
|
||||||
) : null}
|
{...restProps}
|
||||||
{domain.cert_id ? (
|
/>
|
||||||
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
|
);
|
||||||
TLS
|
}
|
||||||
</span>
|
|
||||||
) : null}
|
function HeroTextArea(
|
||||||
</div>
|
props: React.ComponentProps<typeof Textarea> & { className?: string },
|
||||||
</button>
|
) {
|
||||||
))
|
const { className, ...restProps } = props;
|
||||||
) : (
|
|
||||||
<p className="rounded-xl border border-dashed border-[var(--border-default)] px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
return (
|
||||||
未发现匹配域名
|
<Textarea
|
||||||
</p>
|
variant="bordered"
|
||||||
)}
|
size="sm"
|
||||||
</div>
|
radius="lg"
|
||||||
</div>
|
classNames={{
|
||||||
) : null}
|
...HERO_TEXTAREA_CLASS_NAMES,
|
||||||
</div>
|
base: cn(HERO_TEXTAREA_CLASS_NAMES.base, className),
|
||||||
</ResourceField>
|
}}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedKeys(value: string) {
|
||||||
|
return value ? new Set([value]) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroSelectField({
|
||||||
|
ariaLabel,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
children: React.ComponentProps<typeof Select>['children'];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
variant="bordered"
|
||||||
|
radius="lg"
|
||||||
|
disallowEmptySelection
|
||||||
|
selectedKeys={getSelectedKeys(value)}
|
||||||
|
isDisabled={disabled}
|
||||||
|
classNames={HERO_SELECT_CLASS_NAMES}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
if (keys === 'all') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nextValue] = Array.from(keys);
|
||||||
|
if (typeof nextValue === 'string') {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,9 +1160,6 @@ export function ProxyRoutesPage() {
|
|||||||
useState<ManagedDomainMatchResult | null>(null);
|
useState<ManagedDomainMatchResult | null>(null);
|
||||||
const [isMatching, setIsMatching] = useState(false);
|
const [isMatching, setIsMatching] = useState(false);
|
||||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||||
const [activeOriginRowIndex, setActiveOriginRowIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const form = useForm<ProxyRouteFormValues>({
|
const form = useForm<ProxyRouteFormValues>({
|
||||||
resolver: zodResolver(proxyRouteSchema),
|
resolver: zodResolver(proxyRouteSchema),
|
||||||
@@ -1209,7 +1412,6 @@ export function ProxyRoutesPage() {
|
|||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
setMatchResult(null);
|
setMatchResult(null);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(false);
|
||||||
setActiveOriginRowIndex(null);
|
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1512,9 +1714,9 @@ export function ProxyRoutesPage() {
|
|||||||
hint="当前选择的是通配符域名,仅输入最前面的前缀即可。"
|
hint="当前选择的是通配符域名,仅输入最前面的前缀即可。"
|
||||||
error={form.formState.errors.subdomain_label?.message}
|
error={form.formState.errors.subdomain_label?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label="Subdomain Prefix"
|
||||||
placeholder="e.g. ai"
|
placeholder="e.g. ai"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register('subdomain_label')}
|
{...form.register('subdomain_label')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1547,29 +1749,18 @@ export function ProxyRoutesPage() {
|
|||||||
{originFields.map((field, index) => {
|
{originFields.map((field, index) => {
|
||||||
const currentRow =
|
const currentRow =
|
||||||
watchedOriginRows?.[index] ?? defaultValues.origin_rows[0];
|
watchedOriginRows?.[index] ?? defaultValues.origin_rows[0];
|
||||||
const keyword = currentRow.address.trim().toLowerCase();
|
|
||||||
const suggestions = origins.filter((origin) => {
|
|
||||||
if (!keyword) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
origin.address.toLowerCase().includes(keyword) ||
|
|
||||||
origin.name.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.id} className="space-y-2">
|
<div key={field.id} className="space-y-2">
|
||||||
<div className="grid gap-3 md:grid-cols-[120px_minmax(0,1fr)_96px_44px]">
|
<div className="grid gap-3 md:grid-cols-[120px_minmax(0,1fr)_96px_44px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="sr-only">{`协议 ${index + 1}`}</span>
|
<span className="sr-only">{`协议 ${index + 1}`}</span>
|
||||||
<ResourceSelect
|
<HeroSelectField
|
||||||
|
ariaLabel={`协议 ${index + 1}`}
|
||||||
value={currentRow.scheme}
|
value={currentRow.scheme}
|
||||||
className={INPUT_CLASS_NAME}
|
onChange={(nextValue) => {
|
||||||
onChange={(event) => {
|
|
||||||
const nextScheme =
|
const nextScheme =
|
||||||
event.target.value as OriginRowFormValue['scheme'];
|
nextValue as OriginRowFormValue['scheme'];
|
||||||
form.setValue(
|
form.setValue(
|
||||||
`origin_rows.${index}.scheme`,
|
`origin_rows.${index}.scheme`,
|
||||||
nextScheme,
|
nextScheme,
|
||||||
@@ -1598,71 +1789,27 @@ export function ProxyRoutesPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="https">HTTPS</option>
|
<SelectItem key="https">HTTPS</SelectItem>
|
||||||
<option value="http">HTTP</option>
|
<SelectItem key="http">HTTP</SelectItem>
|
||||||
</ResourceSelect>
|
</HeroSelectField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<SearchableOriginAddressField
|
||||||
<span className="sr-only">{`源站地址 ${index + 1}`}</span>
|
value={currentRow.address}
|
||||||
<ResourceInput
|
origins={origins}
|
||||||
placeholder="192.168.1.45"
|
onValueChange={(nextValue) =>
|
||||||
className={INPUT_CLASS_NAME}
|
form.setValue(`origin_rows.${index}.address`, nextValue, {
|
||||||
{...form.register(`origin_rows.${index}.address`)}
|
shouldDirty: true,
|
||||||
onFocus={() => setActiveOriginRowIndex(index)}
|
shouldValidate: true,
|
||||||
/>
|
})
|
||||||
{activeOriginRowIndex === index && keyword ? (
|
}
|
||||||
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-soft)]">
|
/>
|
||||||
{suggestions.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{suggestions.map((origin) => (
|
|
||||||
<button
|
|
||||||
key={origin.id}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
form.setValue(
|
|
||||||
`origin_rows.${index}.address`,
|
|
||||||
origin.address,
|
|
||||||
{
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setActiveOriginRowIndex(null);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]"
|
|
||||||
>
|
|
||||||
<span className="text-[var(--foreground-primary)]">
|
|
||||||
{origin.address}
|
|
||||||
{origin.name ? (
|
|
||||||
<span className="ml-1 text-[var(--foreground-muted)]">
|
|
||||||
({origin.name})
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
{isLocalOriginAddress(origin.address) ? (
|
|
||||||
<span className="rounded-md bg-[var(--accent-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-secondary)]">
|
|
||||||
Local
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-dashed border-[var(--border-default)] px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
|
||||||
未发现匹配资产,请手动输入
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="sr-only">{`端口 ${index + 1}`}</span>
|
<span className="sr-only">{`端口 ${index + 1}`}</span>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label={`端口 ${index + 1}`}
|
||||||
placeholder="443"
|
placeholder="443"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register(`origin_rows.${index}.port`)}
|
{...form.register(`origin_rows.${index}.port`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1722,9 +1869,9 @@ export function ProxyRoutesPage() {
|
|||||||
hint="保留原有高级能力,可选填写 /api 或 ?token=demo。"
|
hint="保留原有高级能力,可选填写 /api 或 ?token=demo。"
|
||||||
error={form.formState.errors.origin_uri?.message}
|
error={form.formState.errors.origin_uri?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label="Origin Path / Query"
|
||||||
placeholder="/api"
|
placeholder="/api"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register('origin_uri')}
|
{...form.register('origin_uri')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1790,24 +1937,41 @@ export function ProxyRoutesPage() {
|
|||||||
)}
|
)}
|
||||||
error={form.formState.errors.cert_id?.message}
|
error={form.formState.errors.cert_id?.message}
|
||||||
>
|
>
|
||||||
<ResourceSelect
|
<HeroSelectField
|
||||||
|
ariaLabel="Select Certificate"
|
||||||
value={watchedCertId}
|
value={watchedCertId}
|
||||||
disabled={certificatesQuery.isLoading}
|
disabled={certificatesQuery.isLoading}
|
||||||
className={INPUT_CLASS_NAME}
|
onChange={(nextValue) =>
|
||||||
onChange={(event) =>
|
form.setValue('cert_id', nextValue, {
|
||||||
form.setValue('cert_id', event.target.value, {
|
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">请选择证书</option>
|
{matchResult?.candidate ? (
|
||||||
{certificates.map((certificate) => (
|
<SelectSection title="自动匹配">
|
||||||
<option key={certificate.id} value={certificate.id}>
|
<SelectItem key={String(matchResult.candidate.certificate_id)}>
|
||||||
{buildCertificateLabel(certificate)}
|
{matchResult.candidate.certificate_name}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
</SelectSection>
|
||||||
</ResourceSelect>
|
) : null}
|
||||||
|
<SelectSection title="全部证书">
|
||||||
|
{certificates
|
||||||
|
.filter(
|
||||||
|
(certificate) =>
|
||||||
|
String(certificate.id) !==
|
||||||
|
String(matchResult?.candidate?.certificate_id ?? ''),
|
||||||
|
)
|
||||||
|
.map((certificate) => (
|
||||||
|
<SelectItem
|
||||||
|
key={String(certificate.id)}
|
||||||
|
textValue={buildCertificateLabel(certificate)}
|
||||||
|
>
|
||||||
|
{buildCertificateLabel(certificate)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectSection>
|
||||||
|
</HeroSelectField>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
@@ -1870,9 +2034,9 @@ export function ProxyRoutesPage() {
|
|||||||
hint="留空则默认使用访问域名 $host。"
|
hint="留空则默认使用访问域名 $host。"
|
||||||
error={form.formState.errors.origin_host?.message}
|
error={form.formState.errors.origin_host?.message}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label="Origin Host Header"
|
||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register('origin_host')}
|
{...form.register('origin_host')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1902,14 +2066,14 @@ export function ProxyRoutesPage() {
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
className="grid gap-3 md:grid-cols-[1fr_1fr_28px]"
|
className="grid gap-3 md:grid-cols-[1fr_1fr_28px]"
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label={`Custom Header Key ${index + 1}`}
|
||||||
placeholder="X-Forwarded-For"
|
placeholder="X-Forwarded-For"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register(`custom_headers.${index}.key`)}
|
{...form.register(`custom_headers.${index}.key`)}
|
||||||
/>
|
/>
|
||||||
<ResourceInput
|
<HeroTextInput
|
||||||
|
aria-label={`Custom Header Value ${index + 1}`}
|
||||||
placeholder="$remote_addr"
|
placeholder="$remote_addr"
|
||||||
className={INPUT_CLASS_NAME}
|
|
||||||
{...form.register(`custom_headers.${index}.value`)}
|
{...form.register(`custom_headers.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -1965,15 +2129,14 @@ export function ProxyRoutesPage() {
|
|||||||
label="缓存策略"
|
label="缓存策略"
|
||||||
hint="按 URL 会缓存所有符合安全条件的 URL;其余策略会先匹配规则再决定是否缓存。"
|
hint="按 URL 会缓存所有符合安全条件的 URL;其余策略会先匹配规则再决定是否缓存。"
|
||||||
>
|
>
|
||||||
<ResourceSelect
|
<HeroSelectField
|
||||||
|
ariaLabel="缓存策略"
|
||||||
value={watchedCachePolicy}
|
value={watchedCachePolicy}
|
||||||
disabled={!watchedCacheEnabled}
|
disabled={!watchedCacheEnabled}
|
||||||
className={INPUT_CLASS_NAME}
|
onChange={(nextValue) =>
|
||||||
onChange={(event) =>
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
'cache_policy',
|
'cache_policy',
|
||||||
event.target
|
nextValue as ProxyRouteFormValues['cache_policy'],
|
||||||
.value as ProxyRouteFormValues['cache_policy'],
|
|
||||||
{
|
{
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
@@ -1981,11 +2144,11 @@ export function ProxyRoutesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="url">按 URL 缓存</option>
|
<SelectItem key="url">按 URL 缓存</SelectItem>
|
||||||
<option value="suffix">按后缀匹配缓存</option>
|
<SelectItem key="suffix">按后缀匹配缓存</SelectItem>
|
||||||
<option value="path_prefix">按路径前缀缓存</option>
|
<SelectItem key="path_prefix">按路径前缀缓存</SelectItem>
|
||||||
<option value="path_exact">按精确路径缓存</option>
|
<SelectItem key="path_exact">按精确路径缓存</SelectItem>
|
||||||
</ResourceSelect>
|
</HeroSelectField>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
@@ -1993,7 +2156,8 @@ export function ProxyRoutesPage() {
|
|||||||
hint={getCacheRulesHint(watchedCachePolicy)}
|
hint={getCacheRulesHint(watchedCachePolicy)}
|
||||||
error={form.formState.errors.cache_rules_text?.message}
|
error={form.formState.errors.cache_rules_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<HeroTextArea
|
||||||
|
aria-label="缓存规则"
|
||||||
placeholder={
|
placeholder={
|
||||||
watchedCachePolicy === 'suffix'
|
watchedCachePolicy === 'suffix'
|
||||||
? 'jpg\ncss\njs'
|
? 'jpg\ncss\njs'
|
||||||
@@ -2006,7 +2170,7 @@ export function ProxyRoutesPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
!watchedCacheEnabled || watchedCachePolicy === 'url'
|
!watchedCacheEnabled || watchedCachePolicy === 'url'
|
||||||
}
|
}
|
||||||
className="min-h-32 rounded-xl"
|
className="min-h-32"
|
||||||
{...form.register('cache_rules_text')}
|
{...form.register('cache_rules_text')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -2017,9 +2181,9 @@ export function ProxyRoutesPage() {
|
|||||||
label="Remarks"
|
label="Remarks"
|
||||||
error={form.formState.errors.remark?.message}
|
error={form.formState.errors.remark?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<HeroTextArea
|
||||||
|
aria-label="Remarks"
|
||||||
placeholder="Internal notes for this rule..."
|
placeholder="Internal notes for this rule..."
|
||||||
className="min-h-28 rounded-xl"
|
|
||||||
{...form.register('remark')}
|
{...form.register('remark')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function findManagedDomainForRoute(
|
|||||||
|
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
return {
|
return {
|
||||||
managedDomainId: String(exactMatch.id),
|
managedDomainId: exactMatch.domain,
|
||||||
subdomainLabel: '',
|
subdomainLabel: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export function findManagedDomainForRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
managedDomainId: String(wildcardMatch.id),
|
managedDomainId: wildcardMatch.domain,
|
||||||
subdomainLabel: normalizedRouteDomain.slice(
|
subdomainLabel: normalizedRouteDomain.slice(
|
||||||
0,
|
0,
|
||||||
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
|
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroui/input": "^2.4.33",
|
||||||
|
"@heroui/select": "^2.4.33",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+1132
-5
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,13 @@ describe('ProxyRoutesPage', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseFetchStub({
|
function buildBaseFetchStub({
|
||||||
|
proxyRoutes = [],
|
||||||
managedDomains,
|
managedDomains,
|
||||||
origins = [],
|
origins = [],
|
||||||
certificates = [{ id: 1, name: 'example-cert', not_after: null }],
|
certificates = [{ id: 1, name: 'example-cert', not_after: null }],
|
||||||
matchResult,
|
matchResult,
|
||||||
}: {
|
}: {
|
||||||
|
proxyRoutes?: Array<Record<string, unknown>>;
|
||||||
managedDomains: Array<Record<string, unknown>>;
|
managedDomains: Array<Record<string, unknown>>;
|
||||||
origins?: Array<Record<string, unknown>>;
|
origins?: Array<Record<string, unknown>>;
|
||||||
certificates?: Array<Record<string, unknown>>;
|
certificates?: Array<Record<string, unknown>>;
|
||||||
@@ -56,7 +58,13 @@ describe('ProxyRoutesPage', () => {
|
|||||||
|
|
||||||
if (url.includes('/proxy-routes/')) {
|
if (url.includes('/proxy-routes/')) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(JSON.stringify({ success: true, message: '', data: [] })),
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: proxyRoutes,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,4 +352,60 @@ describe('ProxyRoutesPage', () => {
|
|||||||
expect(domainInput).toHaveValue('app.example.com');
|
expect(domainInput).toHaveValue('app.example.com');
|
||||||
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefills managed domain and origin rows correctly when editing an existing rule', async () => {
|
||||||
|
stubMatchMedia();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
buildBaseFetchStub({
|
||||||
|
proxyRoutes: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
domain: 'ai.example.com',
|
||||||
|
origin_id: 1,
|
||||||
|
origin_url: 'https://c2.internal:443',
|
||||||
|
origin_host: '',
|
||||||
|
upstreams: JSON.stringify(['https://c2.internal:443']),
|
||||||
|
enabled: true,
|
||||||
|
enable_https: true,
|
||||||
|
cert_id: 1,
|
||||||
|
redirect_http: false,
|
||||||
|
cache_enabled: false,
|
||||||
|
cache_policy: 'url',
|
||||||
|
cache_rules: '[]',
|
||||||
|
custom_headers: '[]',
|
||||||
|
remark: '',
|
||||||
|
created_at: '2026-03-20T08:00:00Z',
|
||||||
|
updated_at: '2026-03-20T08:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
managedDomains: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
domain: '*.example.com',
|
||||||
|
cert_id: 1,
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
created_at: '2026-03-20T08:00:00Z',
|
||||||
|
updated_at: '2026-03-20T08:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderProxyRoutesPage();
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(await screen.findByRole('button', { name: '编辑' }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: '编辑规则' });
|
||||||
|
|
||||||
|
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
|
||||||
|
expect(domainInput).toHaveValue('*.example.com');
|
||||||
|
expect(within(dialog).getByPlaceholderText('e.g. ai')).toHaveValue('ai');
|
||||||
|
expect(within(dialog).getAllByPlaceholderText('192.168.1.45')).toHaveLength(1);
|
||||||
|
expect(within(dialog).getByPlaceholderText('192.168.1.45')).toHaveValue(
|
||||||
|
'c2.internal',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user