mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
[功能] 添加抽屉组件并重构代理路由页面,优化规则创建体验
This commit is contained in:
@@ -0,0 +1,312 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
|
||||||
|
type DrawerContextValue = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
direction: DrawerDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
||||||
|
|
||||||
|
function useDrawerContext() {
|
||||||
|
const context = useContext(DrawerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Drawer components must be used within Drawer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithOptionalChild(
|
||||||
|
child: ReactNode,
|
||||||
|
props: Record<string, unknown>,
|
||||||
|
fallback: ReactNode,
|
||||||
|
) {
|
||||||
|
if (isValidElement(child)) {
|
||||||
|
return cloneElement(child as ReactElement, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Drawer({
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
defaultOpen = false,
|
||||||
|
onOpenChange,
|
||||||
|
direction = 'bottom',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
direction?: DrawerDirection;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
size?: 'md' | 'lg' | 'xl';
|
||||||
|
}) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||||
|
const isControlled = open !== undefined;
|
||||||
|
const resolvedOpen = isControlled ? open : internalOpen;
|
||||||
|
|
||||||
|
const value = useMemo<DrawerContextValue>(
|
||||||
|
() => ({
|
||||||
|
open: resolvedOpen,
|
||||||
|
direction,
|
||||||
|
setOpen: (nextOpen) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalOpen(nextOpen);
|
||||||
|
}
|
||||||
|
onOpenChange?.(nextOpen);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[direction, isControlled, onOpenChange, resolvedOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContext.Provider value={value}>
|
||||||
|
{title || description || footer ? (
|
||||||
|
<DrawerContent
|
||||||
|
aria-label={title}
|
||||||
|
className={cn(
|
||||||
|
'w-full md:w-[50vw] md:max-w-none',
|
||||||
|
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DrawerHeader className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
|
||||||
|
{description ? (
|
||||||
|
<DrawerDescription>{description}</DrawerDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => value.setOpen(false)}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
|
aria-label="关闭抽屉"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
|
||||||
|
</DrawerContent>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</DrawerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerTrigger({
|
||||||
|
children,
|
||||||
|
asChild = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const { setOpen } = useDrawerContext();
|
||||||
|
|
||||||
|
if (asChild) {
|
||||||
|
return renderWithOptionalChild(
|
||||||
|
children,
|
||||||
|
{
|
||||||
|
onClick: () => setOpen(true),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerClose({
|
||||||
|
children,
|
||||||
|
asChild = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const { setOpen } = useDrawerContext();
|
||||||
|
|
||||||
|
if (asChild) {
|
||||||
|
return renderWithOptionalChild(
|
||||||
|
children,
|
||||||
|
{
|
||||||
|
onClick: () => setOpen(false),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => setOpen(false)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { open, setOpen, direction } = useDrawerContext();
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
if (!open || typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionClassName =
|
||||||
|
direction === 'right'
|
||||||
|
? 'inset-y-0 right-0 h-full border-l'
|
||||||
|
: direction === 'left'
|
||||||
|
? 'inset-y-0 left-0 h-full border-r'
|
||||||
|
: direction === 'top'
|
||||||
|
? 'inset-x-0 top-0 border-b'
|
||||||
|
: 'inset-x-0 bottom-0 border-t';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="关闭抽屉"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
className={cn(
|
||||||
|
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||||
|
positionClassName,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
|
||||||
|
{children}
|
||||||
|
</DrawerMetaContext.Provider>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawerMetaContextValue = {
|
||||||
|
titleId: string;
|
||||||
|
descriptionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
|
||||||
|
|
||||||
|
function useDrawerMetaContext() {
|
||||||
|
const context = useContext(DrawerMetaContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Drawer title and description must be used within DrawerContent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
const { titleId } = useDrawerMetaContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
const { descriptionId } = useDrawerMetaContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
id={descriptionId}
|
||||||
|
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ import { InlineMessage } from '@/components/feedback/inline-message';
|
|||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { Drawer } from '@/components/ui/drawer';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -293,7 +293,7 @@ const HERO_SELECT_LISTBOX_CLASS_NAME = 'p-1';
|
|||||||
const defaultValues: ProxyRouteFormValues = {
|
const defaultValues: ProxyRouteFormValues = {
|
||||||
managed_domain_id: '',
|
managed_domain_id: '',
|
||||||
subdomain_label: '',
|
subdomain_label: '',
|
||||||
origin_rows: [{ scheme: 'https', address: '', port: '443' }],
|
origin_rows: [{ scheme: 'http', address: '', port: '80' }],
|
||||||
origin_uri: '',
|
origin_uri: '',
|
||||||
origin_host: '',
|
origin_host: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -1077,18 +1077,21 @@ function HeroTextArea(
|
|||||||
function HeroSelectField({
|
function HeroSelectField({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
value,
|
value,
|
||||||
|
displayValue,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
displayValue?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
key={`${ariaLabel}-${value || 'empty'}`}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
selectedKey={value || null}
|
selectedKey={value || null}
|
||||||
@@ -1102,7 +1105,11 @@ function HeroSelectField({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Trigger className={HERO_SELECT_TRIGGER_CLASS_NAME}>
|
<Select.Trigger className={HERO_SELECT_TRIGGER_CLASS_NAME}>
|
||||||
<Select.Value className={HERO_SELECT_VALUE_CLASS_NAME} />
|
{displayValue ? (
|
||||||
|
<span className={HERO_SELECT_VALUE_CLASS_NAME}>{displayValue}</span>
|
||||||
|
) : (
|
||||||
|
<Select.Value className={HERO_SELECT_VALUE_CLASS_NAME} />
|
||||||
|
)}
|
||||||
<Select.Indicator />
|
<Select.Indicator />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Popover className={HERO_SELECT_POPOVER_CLASS_NAME}>
|
<Select.Popover className={HERO_SELECT_POPOVER_CLASS_NAME}>
|
||||||
@@ -1263,6 +1270,16 @@ export function ProxyRoutesPage() {
|
|||||||
|
|
||||||
const primaryOriginRow =
|
const primaryOriginRow =
|
||||||
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
|
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
|
||||||
|
const primaryOriginPreview = buildOriginUrl(
|
||||||
|
primaryOriginRow.scheme,
|
||||||
|
primaryOriginRow.address,
|
||||||
|
primaryOriginRow.port,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const certificatePreview =
|
||||||
|
certificates.find((item) => String(item.id) === watchedCertId)?.name ??
|
||||||
|
matchResult?.candidate?.certificate_name ??
|
||||||
|
(watchedEnableHttps ? '待选择' : '未启用');
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||||
const payload = toPayload(values, origins);
|
const payload = toPayload(values, origins);
|
||||||
@@ -1278,7 +1295,7 @@ export function ProxyRoutesPage() {
|
|||||||
setEditingRouteId(null);
|
setEditingRouteId(null);
|
||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
setMatchResult(null);
|
setMatchResult(null);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(true);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
|
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
|
||||||
@@ -1387,7 +1404,7 @@ export function ProxyRoutesPage() {
|
|||||||
setEditingRouteId(null);
|
setEditingRouteId(null);
|
||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
setMatchResult(null);
|
setMatchResult(null);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(true);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1395,7 +1412,7 @@ export function ProxyRoutesPage() {
|
|||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setEditingRouteId(null);
|
setEditingRouteId(null);
|
||||||
setMatchResult(null);
|
setMatchResult(null);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(true);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
@@ -1411,7 +1428,7 @@ export function ProxyRoutesPage() {
|
|||||||
setMatchResult(null);
|
setMatchResult(null);
|
||||||
try {
|
try {
|
||||||
form.reset(toFormValues(route, managedDomains));
|
form.reset(toFormValues(route, managedDomains));
|
||||||
setIsAdvancedOpen(Boolean(route.origin_host || route.remark));
|
setIsAdvancedOpen(true);
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
@@ -1585,9 +1602,14 @@ export function ProxyRoutesPage() {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppModal
|
<Drawer
|
||||||
isOpen={isEditorOpen}
|
open={isEditorOpen}
|
||||||
onClose={handleReset}
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
handleReset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
direction="right"
|
||||||
title={editingRouteId ? '编辑规则' : '新增规则'}
|
title={editingRouteId ? '编辑规则' : '新增规则'}
|
||||||
description="新增或修改反代规则后,可直接回到列表页继续发布。"
|
description="新增或修改反代规则后,可直接回到列表页继续发布。"
|
||||||
size="xl"
|
size="xl"
|
||||||
@@ -1619,6 +1641,35 @@ export function ProxyRoutesPage() {
|
|||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
|
<section className="grid gap-3 rounded-3xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_84%,white_16%)] p-4 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_auto]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||||
|
Rule
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{effectiveDomain || '未选择域名'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||||
|
Origin
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{primaryOriginPreview || '未填写源站'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-start gap-2 md:justify-end">
|
||||||
|
<StatusBadge
|
||||||
|
label={watchedEnabled ? '已启用' : '已停用'}
|
||||||
|
variant={watchedEnabled ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
label={watchedEnableHttps ? `HTTPS · ${certificatePreview}` : 'HTTP'}
|
||||||
|
variant={watchedEnableHttps ? 'info' : 'warning'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HeroSwitchField
|
<HeroSwitchField
|
||||||
id="proxy-route-enabled"
|
id="proxy-route-enabled"
|
||||||
label="启用规则"
|
label="启用规则"
|
||||||
@@ -1699,6 +1750,13 @@ export function ProxyRoutesPage() {
|
|||||||
<HeroSelectField
|
<HeroSelectField
|
||||||
ariaLabel={`协议 ${index + 1}`}
|
ariaLabel={`协议 ${index + 1}`}
|
||||||
value={currentRow.scheme}
|
value={currentRow.scheme}
|
||||||
|
displayValue={
|
||||||
|
currentRow.scheme === 'https'
|
||||||
|
? 'HTTPS'
|
||||||
|
: currentRow.scheme === 'http'
|
||||||
|
? 'HTTP'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(nextValue) => {
|
onChange={(nextValue) => {
|
||||||
const nextScheme =
|
const nextScheme =
|
||||||
nextValue as OriginRowFormValue['scheme'];
|
nextValue as OriginRowFormValue['scheme'];
|
||||||
@@ -1730,8 +1788,12 @@ export function ProxyRoutesPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectItem key="https">HTTPS</SelectItem>
|
<SelectItem key="https" textValue="HTTPS">
|
||||||
<SelectItem key="http">HTTP</SelectItem>
|
HTTPS
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="http" textValue="HTTP">
|
||||||
|
HTTP
|
||||||
|
</SelectItem>
|
||||||
</HeroSelectField>
|
</HeroSelectField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2127,7 +2189,7 @@ export function ProxyRoutesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,6 +270,36 @@ describe('ProxyRoutesPage', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens create drawer with advanced section expanded and http selected by default', async () => {
|
||||||
|
stubMatchMedia();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
buildBaseFetchStub({
|
||||||
|
managedDomains: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
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: '新增规则' });
|
||||||
|
|
||||||
|
expect(within(dialog).getByRole('heading', { name: 'Advanced' })).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByLabelText('协议 1')).toHaveTextContent('HTTP');
|
||||||
|
});
|
||||||
|
|
||||||
it('closes managed domain suggestions when clicking outside', async () => {
|
it('closes managed domain suggestions when clicking outside', async () => {
|
||||||
stubMatchMedia();
|
stubMatchMedia();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user