mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
[功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
@import '../styles/tokens.css';
|
@import '../styles/tokens.css';
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -42,3 +46,127 @@ button {
|
|||||||
background: var(--selection-background);
|
background: var(--selection-background);
|
||||||
color: var(--selection-foreground);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import { AppProviders } from '@/components/providers/app-providers';
|
|||||||
import { getThemeInitScript } from '@/lib/theme/theme';
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -22,7 +26,7 @@ interface RootLayoutProps {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: RootLayoutProps) {
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html lang='zh-CN' suppressHydrationWarning>
|
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
||||||
<body>
|
<body>
|
||||||
<Script id='theme-init' strategy='beforeInteractive'>
|
<Script id='theme-init' strategy='beforeInteractive'>
|
||||||
{getThemeInitScript()}
|
{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';
|
'use client';
|
||||||
|
|
||||||
import { Input, Textarea } from '@heroui/input';
|
|
||||||
import { Select, SelectItem, SelectSection } from '@heroui/select';
|
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';
|
||||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-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 { 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 { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
getConfigVersionDiff,
|
getConfigVersionDiff,
|
||||||
publishConfigVersion,
|
publishConfigVersion,
|
||||||
@@ -274,20 +278,6 @@ 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 = {
|
const HERO_SELECT_CLASS_NAMES = {
|
||||||
base: 'w-full',
|
base: 'w-full',
|
||||||
trigger: cn(
|
trigger: cn(
|
||||||
@@ -1078,13 +1068,7 @@ function HeroTextInput(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
variant="bordered"
|
className={cn(INPUT_CLASS_NAME, className)}
|
||||||
size="sm"
|
|
||||||
radius="lg"
|
|
||||||
classNames={{
|
|
||||||
...HERO_INPUT_CLASS_NAMES,
|
|
||||||
base: cn(HERO_INPUT_CLASS_NAMES.base, className),
|
|
||||||
}}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1097,13 +1081,7 @@ function HeroTextArea(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
variant="bordered"
|
className={className}
|
||||||
size="sm"
|
|
||||||
radius="lg"
|
|
||||||
classNames={{
|
|
||||||
...HERO_TEXTAREA_CLASS_NAMES,
|
|
||||||
base: cn(HERO_TEXTAREA_CLASS_NAMES.base, className),
|
|
||||||
}}
|
|
||||||
{...restProps}
|
{...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() {
|
export function ProxyRoutesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
@@ -1159,7 +1173,7 @@ export function ProxyRoutesPage() {
|
|||||||
const [matchResult, setMatchResult] =
|
const [matchResult, setMatchResult] =
|
||||||
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(true);
|
||||||
|
|
||||||
const form = useForm<ProxyRouteFormValues>({
|
const form = useForm<ProxyRouteFormValues>({
|
||||||
resolver: zodResolver(proxyRouteSchema),
|
resolver: zodResolver(proxyRouteSchema),
|
||||||
@@ -1270,23 +1284,6 @@ export function ProxyRoutesPage() {
|
|||||||
|
|
||||||
const primaryOriginRow =
|
const primaryOriginRow =
|
||||||
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||||
const payload = toPayload(values, origins);
|
const payload = toPayload(values, origins);
|
||||||
@@ -1688,8 +1685,18 @@ export function ProxyRoutesPage() {
|
|||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
|
<HeroSwitchField
|
||||||
|
id="proxy-route-enabled"
|
||||||
|
label="启用规则"
|
||||||
|
description="关闭后该规则不会参与配置渲染与发布。"
|
||||||
|
checked={watchedEnabled}
|
||||||
|
onChange={(checked) =>
|
||||||
|
form.setValue('enabled', checked, { shouldDirty: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProxyRuleSection
|
<ProxyRuleSection
|
||||||
title="域名设置"
|
title="Domain"
|
||||||
icon={<GlobeIcon className="h-4 w-4" />}
|
icon={<GlobeIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1726,7 +1733,7 @@ export function ProxyRoutesPage() {
|
|||||||
</ProxyRuleSection>
|
</ProxyRuleSection>
|
||||||
|
|
||||||
<ProxyRuleSection
|
<ProxyRuleSection
|
||||||
title="Origin Settings"
|
title="Origin"
|
||||||
icon={<ServerIcon className="h-4 w-4" />}
|
icon={<ServerIcon className="h-4 w-4" />}
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
@@ -1841,41 +1848,20 @@ export function ProxyRoutesPage() {
|
|||||||
?.message}
|
?.message}
|
||||||
</p>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</ProxyRuleSection>
|
</ProxyRuleSection>
|
||||||
|
|
||||||
@@ -1884,49 +1870,57 @@ export function ProxyRoutesPage() {
|
|||||||
icon={<ShieldIcon className="h-4 w-4" />}
|
icon={<ShieldIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4">
|
||||||
<ToggleField
|
<div className="flex items-start justify-between gap-4">
|
||||||
label="HTTPS Access"
|
<div className="space-y-1">
|
||||||
description="若所选域名已有证书,系统会默认开启;开启后即可选择或调整证书。"
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
checked={watchedEnableHttps}
|
HTTPS Access
|
||||||
onChange={(checked) => {
|
</p>
|
||||||
form.setValue('enable_https', checked, {
|
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
|
||||||
shouldDirty: true,
|
Enforce secure TLS connections for this rule.
|
||||||
shouldValidate: true,
|
</p>
|
||||||
});
|
</div>
|
||||||
|
<Switch
|
||||||
if (checked && !form.getValues('cert_id') && selectedManagedDomain?.cert_id) {
|
id="proxy-route-enable-https"
|
||||||
form.setValue('cert_id', String(selectedManagedDomain.cert_id), {
|
checked={watchedEnableHttps}
|
||||||
|
aria-label="HTTPS Access"
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
form.setValue('enable_https', checked, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!checked) {
|
if (
|
||||||
form.setValue('cert_id', '', {
|
checked &&
|
||||||
shouldDirty: true,
|
!form.getValues('cert_id') &&
|
||||||
shouldValidate: true,
|
selectedManagedDomain?.cert_id
|
||||||
});
|
) {
|
||||||
form.setValue('redirect_http', false, {
|
form.setValue(
|
||||||
shouldDirty: true,
|
'cert_id',
|
||||||
shouldValidate: true,
|
String(selectedManagedDomain.cert_id),
|
||||||
});
|
{
|
||||||
}
|
shouldDirty: true,
|
||||||
}}
|
shouldValidate: true,
|
||||||
/>
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<ToggleField
|
if (!checked) {
|
||||||
label="启用规则"
|
form.setValue('cert_id', '', {
|
||||||
description="关闭后该规则不会参与配置渲染与发布。"
|
shouldDirty: true,
|
||||||
checked={watchedEnabled}
|
shouldValidate: true,
|
||||||
onChange={(checked) =>
|
});
|
||||||
form.setValue('enabled', checked, { shouldDirty: true })
|
form.setValue('redirect_http', false, {
|
||||||
}
|
shouldDirty: true,
|
||||||
/>
|
shouldValidate: true,
|
||||||
</div>
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{watchedEnableHttps ? (
|
{watchedEnableHttps ? (
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
<div className="mt-5 space-y-4">
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="Select Certificate"
|
label="Select Certificate"
|
||||||
hint={getMatchMessage(
|
hint={getMatchMessage(
|
||||||
@@ -1974,20 +1968,21 @@ export function ProxyRoutesPage() {
|
|||||||
</HeroSelectField>
|
</HeroSelectField>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="HTTP 跳转 HTTPS"
|
label="HTTP 跳转 HTTPS"
|
||||||
description="开启后会将 HTTP 请求重定向到 HTTPS。"
|
description="开启后会将 HTTP 请求重定向到 HTTPS。"
|
||||||
checked={watchedRedirectHttp}
|
checked={watchedRedirectHttp}
|
||||||
disabled={!watchedEnableHttps}
|
disabled={!watchedEnableHttps}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
form.setValue('redirect_http', checked, {
|
form.setValue('redirect_http', checked, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -2016,7 +2011,7 @@ export function ProxyRoutesPage() {
|
|||||||
<SlidersIcon className="h-4 w-4" />
|
<SlidersIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
|
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
|
||||||
Advanced Settings
|
Advanced
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<ChevronIcon
|
<ChevronIcon
|
||||||
@@ -2027,7 +2022,7 @@ export function ProxyRoutesPage() {
|
|||||||
|
|
||||||
{isAdvancedOpen ? (
|
{isAdvancedOpen ? (
|
||||||
<div className={cn(PANEL_CLASS_NAME, 'space-y-4')}>
|
<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">
|
<div className="space-y-4">
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="Origin Host Header"
|
label="Origin Host Header"
|
||||||
@@ -2101,7 +2096,7 @@ export function ProxyRoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="Enable Rule Caching"
|
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": {
|
"dependencies": {
|
||||||
"@heroui/input": "^2.4.33",
|
"@heroui/input": "^2.4.33",
|
||||||
|
"@heroui/react": "^2.8.10",
|
||||||
"@heroui/select": "^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",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"next": "15.2.2",
|
"next": "15.2.2",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"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",
|
"zod": "^3.24.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+5474
-9
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user