[功能] 添加抽屉组件并重构代理路由页面,优化规则创建体验

This commit is contained in:
ryan
2026-03-30 10:32:49 +08:00
parent 6e109fd3f7
commit fdadd76945
3 changed files with 417 additions and 13 deletions
@@ -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 { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { Drawer } from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
@@ -293,7 +293,7 @@ const HERO_SELECT_LISTBOX_CLASS_NAME = 'p-1';
const defaultValues: ProxyRouteFormValues = {
managed_domain_id: '',
subdomain_label: '',
origin_rows: [{ scheme: 'https', address: '', port: '443' }],
origin_rows: [{ scheme: 'http', address: '', port: '80' }],
origin_uri: '',
origin_host: '',
enabled: true,
@@ -1077,18 +1077,21 @@ function HeroTextArea(
function HeroSelectField({
ariaLabel,
value,
displayValue,
disabled,
onChange,
children,
}: {
ariaLabel: string;
value: string;
displayValue?: string;
disabled?: boolean;
onChange: (value: string) => void;
children: React.ReactNode;
}) {
return (
<Select
key={`${ariaLabel}-${value || 'empty'}`}
aria-label={ariaLabel}
variant="primary"
selectedKey={value || null}
@@ -1102,7 +1105,11 @@ function HeroSelectField({
}}
>
<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.Trigger>
<Select.Popover className={HERO_SELECT_POPOVER_CLASS_NAME}>
@@ -1263,6 +1270,16 @@ export function ProxyRoutesPage() {
const primaryOriginRow =
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({
mutationFn: async (values: ProxyRouteFormValues) => {
const payload = toPayload(values, origins);
@@ -1278,7 +1295,7 @@ export function ProxyRoutesPage() {
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
setIsAdvancedOpen(false);
setIsAdvancedOpen(true);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
@@ -1387,7 +1404,7 @@ export function ProxyRoutesPage() {
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
setIsAdvancedOpen(false);
setIsAdvancedOpen(true);
form.reset(defaultValues);
};
@@ -1395,7 +1412,7 @@ export function ProxyRoutesPage() {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
setIsAdvancedOpen(false);
setIsAdvancedOpen(true);
form.reset(defaultValues);
setIsEditorOpen(true);
};
@@ -1411,7 +1428,7 @@ export function ProxyRoutesPage() {
setMatchResult(null);
try {
form.reset(toFormValues(route, managedDomains));
setIsAdvancedOpen(Boolean(route.origin_host || route.remark));
setIsAdvancedOpen(true);
setIsEditorOpen(true);
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -1585,9 +1602,14 @@ export function ProxyRoutesPage() {
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
<Drawer
open={isEditorOpen}
onOpenChange={(open) => {
if (!open) {
handleReset();
}
}}
direction="right"
title={editingRouteId ? '编辑规则' : '新增规则'}
description="新增或修改反代规则后,可直接回到列表页继续发布。"
size="xl"
@@ -1619,6 +1641,35 @@ export function ProxyRoutesPage() {
className="space-y-6"
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
id="proxy-route-enabled"
label="启用规则"
@@ -1699,6 +1750,13 @@ export function ProxyRoutesPage() {
<HeroSelectField
ariaLabel={`协议 ${index + 1}`}
value={currentRow.scheme}
displayValue={
currentRow.scheme === 'https'
? 'HTTPS'
: currentRow.scheme === 'http'
? 'HTTP'
: undefined
}
onChange={(nextValue) => {
const nextScheme =
nextValue as OriginRowFormValue['scheme'];
@@ -1730,8 +1788,12 @@ export function ProxyRoutesPage() {
}
}}
>
<SelectItem key="https">HTTPS</SelectItem>
<SelectItem key="http">HTTP</SelectItem>
<SelectItem key="https" textValue="HTTPS">
HTTPS
</SelectItem>
<SelectItem key="http" textValue="HTTP">
HTTP
</SelectItem>
</HeroSelectField>
</div>
@@ -2127,7 +2189,7 @@ export function ProxyRoutesPage() {
) : null}
</section>
</form>
</AppModal>
</Drawer>
</>
);
}
@@ -270,6 +270,36 @@ describe('ProxyRoutesPage', () => {
).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 () => {
stubMatchMedia();