mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +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 { 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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user