[功能] 更新代理路由页面,集成新的输入和选择组件,优化域名选择逻辑

This commit is contained in:
ryan
2026-03-20 22:52:57 +08:00
parent 1fd4b22b9c
commit 2c87254bb3
5 changed files with 1642 additions and 285 deletions
@@ -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,
+2
View File
@@ -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",
+1132 -5
View File
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',
);
});
}); });