mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
[功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能
This commit is contained in:
@@ -1,22 +1,26 @@
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--body-background);
|
||||
@@ -28,17 +32,141 @@ body {
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
::selection {
|
||||
background: var(--selection-background);
|
||||
color: var(--selection-foreground);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import type { ReactNode } from 'react';
|
||||
import { AppProviders } from '@/components/providers/app-providers';
|
||||
import { getThemeInitScript } from '@/lib/theme/theme';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
import './globals.css';
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OpenFlare 控制台',
|
||||
@@ -22,7 +26,7 @@ interface RootLayoutProps {
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
||||
<body>
|
||||
<Script id='theme-init' strategy='beforeInteractive'>
|
||||
{getThemeInitScript()}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.ComponentProps<'label'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
|
||||
const isControlled = checked !== undefined;
|
||||
const isChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const updateChecked = React.useCallback(
|
||||
(nextChecked: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
},
|
||||
[isControlled, onCheckedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
|
||||
className,
|
||||
)}
|
||||
onClick={() => updateChecked(!isChecked)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
updateChecked(!isChecked);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { Input, Textarea } from '@heroui/input';
|
||||
import { Select, SelectItem, SelectSection } from '@heroui/select';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
@@ -14,8 +14,12 @@ 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 { 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';
|
||||
import {
|
||||
getConfigVersionDiff,
|
||||
publishConfigVersion,
|
||||
@@ -274,20 +278,6 @@ type SectionIconProps = {
|
||||
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
|
||||
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)]';
|
||||
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(
|
||||
@@ -1078,13 +1068,7 @@ function HeroTextInput(
|
||||
|
||||
return (
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
radius="lg"
|
||||
classNames={{
|
||||
...HERO_INPUT_CLASS_NAMES,
|
||||
base: cn(HERO_INPUT_CLASS_NAMES.base, className),
|
||||
}}
|
||||
className={cn(INPUT_CLASS_NAME, className)}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
@@ -1097,13 +1081,7 @@ function HeroTextArea(
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
radius="lg"
|
||||
classNames={{
|
||||
...HERO_TEXTAREA_CLASS_NAMES,
|
||||
base: cn(HERO_TEXTAREA_CLASS_NAMES.base, className),
|
||||
}}
|
||||
className={className}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
@@ -1151,6 +1129,42 @@ function HeroSelectField({
|
||||
);
|
||||
}
|
||||
|
||||
function HeroSwitchField({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{description ? (
|
||||
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyRoutesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
@@ -1159,7 +1173,7 @@ export function ProxyRoutesPage() {
|
||||
const [matchResult, setMatchResult] =
|
||||
useState<ManagedDomainMatchResult | null>(null);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(true);
|
||||
|
||||
const form = useForm<ProxyRouteFormValues>({
|
||||
resolver: zodResolver(proxyRouteSchema),
|
||||
@@ -1270,23 +1284,6 @@ export function ProxyRoutesPage() {
|
||||
|
||||
const primaryOriginRow =
|
||||
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
|
||||
const matchedOrigin = useMemo(
|
||||
() =>
|
||||
origins.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() ===
|
||||
primaryOriginRow.address.trim().toLowerCase(),
|
||||
) ?? null,
|
||||
[origins, primaryOriginRow.address],
|
||||
);
|
||||
|
||||
const primaryOriginPreview = buildOriginUrl(
|
||||
primaryOriginRow.scheme,
|
||||
primaryOriginRow.address,
|
||||
primaryOriginRow.port,
|
||||
watchedOriginURI,
|
||||
);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||
const payload = toPayload(values, origins);
|
||||
@@ -1688,8 +1685,18 @@ export function ProxyRoutesPage() {
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<HeroSwitchField
|
||||
id="proxy-route-enabled"
|
||||
label="启用规则"
|
||||
description="关闭后该规则不会参与配置渲染与发布。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ProxyRuleSection
|
||||
title="域名设置"
|
||||
title="Domain"
|
||||
icon={<GlobeIcon className="h-4 w-4" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1726,7 +1733,7 @@ export function ProxyRoutesPage() {
|
||||
</ProxyRuleSection>
|
||||
|
||||
<ProxyRuleSection
|
||||
title="Origin Settings"
|
||||
title="Origin"
|
||||
icon={<ServerIcon className="h-4 w-4" />}
|
||||
action={
|
||||
<button
|
||||
@@ -1841,41 +1848,20 @@ export function ProxyRoutesPage() {
|
||||
?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{index === 0 ? (
|
||||
<div>
|
||||
<span className="sr-only">Origin Path / Query</span>
|
||||
<HeroTextInput
|
||||
aria-label="Origin Path / Query"
|
||||
placeholder="/api"
|
||||
{...form.register('origin_uri')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_1fr]">
|
||||
<ResourceField
|
||||
label="Primary Origin Preview"
|
||||
hint={
|
||||
matchedOrigin
|
||||
? `当前会复用已存在的源站 ${matchedOrigin.name}。`
|
||||
: '如果地址尚未收录,保存规则时会自动创建一个新源站。'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-xl border border-dashed border-[var(--border-default)] bg-[var(--surface-panel)] px-3 font-mono text-sm text-[var(--foreground-secondary)]',
|
||||
INPUT_CLASS_NAME,
|
||||
)}
|
||||
>
|
||||
{primaryOriginPreview || '填写主源站后显示完整回源地址'}
|
||||
</div>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="Origin Path / Query"
|
||||
hint="保留原有高级能力,可选填写 /api 或 ?token=demo。"
|
||||
error={form.formState.errors.origin_uri?.message}
|
||||
>
|
||||
<HeroTextInput
|
||||
aria-label="Origin Path / Query"
|
||||
placeholder="/api"
|
||||
{...form.register('origin_uri')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
</ProxyRuleSection>
|
||||
|
||||
@@ -1884,49 +1870,57 @@ export function ProxyRoutesPage() {
|
||||
icon={<ShieldIcon className="h-4 w-4" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<ToggleField
|
||||
label="HTTPS Access"
|
||||
description="若所选域名已有证书,系统会默认开启;开启后即可选择或调整证书。"
|
||||
checked={watchedEnableHttps}
|
||||
onChange={(checked) => {
|
||||
form.setValue('enable_https', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
if (checked && !form.getValues('cert_id') && selectedManagedDomain?.cert_id) {
|
||||
form.setValue('cert_id', String(selectedManagedDomain.cert_id), {
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
HTTPS Access
|
||||
</p>
|
||||
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
|
||||
Enforce secure TLS connections for this rule.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="proxy-route-enable-https"
|
||||
checked={watchedEnableHttps}
|
||||
aria-label="HTTPS Access"
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue('enable_https', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
form.setValue('cert_id', '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue('redirect_http', false, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
if (
|
||||
checked &&
|
||||
!form.getValues('cert_id') &&
|
||||
selectedManagedDomain?.cert_id
|
||||
) {
|
||||
form.setValue(
|
||||
'cert_id',
|
||||
String(selectedManagedDomain.cert_id),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
<ToggleField
|
||||
label="启用规则"
|
||||
description="关闭后该规则不会参与配置渲染与发布。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
if (!checked) {
|
||||
form.setValue('cert_id', '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue('redirect_http', false, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{watchedEnableHttps ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{watchedEnableHttps ? (
|
||||
<div className="mt-5 space-y-4">
|
||||
<ResourceField
|
||||
label="Select Certificate"
|
||||
hint={getMatchMessage(
|
||||
@@ -1974,20 +1968,21 @@ export function ProxyRoutesPage() {
|
||||
</HeroSelectField>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="HTTP 跳转 HTTPS"
|
||||
description="开启后会将 HTTP 请求重定向到 HTTPS。"
|
||||
checked={watchedRedirectHttp}
|
||||
disabled={!watchedEnableHttps}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ToggleField
|
||||
label="HTTP 跳转 HTTPS"
|
||||
description="开启后会将 HTTP 请求重定向到 HTTPS。"
|
||||
checked={watchedRedirectHttp}
|
||||
disabled={!watchedEnableHttps}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -2016,7 +2011,7 @@ export function ProxyRoutesPage() {
|
||||
<SlidersIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
|
||||
Advanced Settings
|
||||
Advanced
|
||||
</h3>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
@@ -2027,7 +2022,7 @@ export function ProxyRoutesPage() {
|
||||
|
||||
{isAdvancedOpen ? (
|
||||
<div className={cn(PANEL_CLASS_NAME, 'space-y-4')}>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid gap-4 lg:grid-cols-1">
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="Origin Host Header"
|
||||
@@ -2101,7 +2096,7 @@ export function ProxyRoutesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<ToggleField
|
||||
label="Enable Rule Caching"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -19,18 +19,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/input": "^2.4.33",
|
||||
"@heroui/react": "^2.8.10",
|
||||
"@heroui/select": "^2.4.33",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.4",
|
||||
"next": "15.2.2",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
Generated
+5474
-9
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user