diff --git a/frontend/package.json b/frontend/package.json index d9c1a4a..c8ce1a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,6 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "^5.97.0", diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx index 0f5dc74..1d33b9e 100644 --- a/frontend/src/components/ui/autocomplete.tsx +++ b/frontend/src/components/ui/autocomplete.tsx @@ -45,6 +45,7 @@ export const AutoCompleteInput = React.forwardRef { const containerRef = React.useRef(null) const [isOpen, setIsOpen] = React.useState(false) + const [searchQuery, setSearchQuery] = React.useState('') React.useEffect(() => { const handlePointerDown = (event: MouseEvent) => { @@ -66,7 +68,13 @@ export const AutoCompleteInput = React.forwardRef document.removeEventListener('mousedown', handlePointerDown) }, []) - const query = normalizeText(value) + React.useEffect(() => { + if (!isOpen) { + setSearchQuery('') + } + }, [isOpen]) + + const query = normalizeText(searchQuery) const dedupedOptions = options.filter((option, index) => { return options.findIndex((candidate) => candidate.value === option.value) === index }) @@ -84,7 +92,13 @@ export const AutoCompleteInput = React.forwardRef { + setSearchQuery('') + setIsOpen(true) + onClick?.(event) + }} onFocus={(event) => { + setSearchQuery('') setIsOpen(true) onFocus?.(event) }} @@ -93,6 +107,7 @@ export const AutoCompleteInput = React.forwardRef { onChange(event.target.value) + setSearchQuery(event.target.value) setIsOpen(true) }} className={inputClassName} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index b2d253c..0a8eb5c 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -1,71 +1,70 @@ -import * as React from "react"; -import * as SelectPrimitive from "@radix-ui/react-select"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; -import { cn } from "@/lib/utils"; +import * as React from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { cn } from '@/lib/utils' -type NativeSelectProps = Omit, "children" | "onChange" | "size">; +type NativeSelectProps = Omit, 'children' | 'onChange' | 'size'> interface SelectProps extends NativeSelectProps { - children: React.ReactNode; - onChange?: (event: React.ChangeEvent) => void; - placeholder?: string; - contentClassName?: string; + children: React.ReactNode + onChange?: (event: React.ChangeEvent) => void + placeholder?: string + contentClassName?: string } interface SelectOption { - value: string; - label: React.ReactNode; - disabled?: boolean; + value: string + label: React.ReactNode + disabled?: boolean } const TRIGGER_BASE_CLASS = - "group relative flex h-10 w-full items-center rounded-lg border border-input/90 bg-card/90 px-3 py-2 pr-12 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-primary/25 hover:bg-card focus-visible:border-primary/55 focus-visible:ring-4 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground"; + 'group relative flex h-10 w-full items-center rounded-lg border border-input bg-transparent px-3 py-2 pr-10 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-muted-foreground/30 focus-visible:border-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50' const CONTENT_BASE_CLASS = - "z-50 max-h-72 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-border/70 bg-popover/95 p-1.5 text-popover-foreground shadow-md backdrop-blur-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 dark:shadow-[0_24px_70px_-24px_hsl(0_0%_0%_/_0.8)]"; + 'absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-lg' const ITEM_BASE_CLASS = - "relative flex w-full select-none items-center rounded-lg px-3 py-2 pr-9 text-xs text-foreground outline-none transition-all duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-primary/10 data-[highlighted]:text-foreground data-[state=checked]:bg-primary/8"; + 'relative flex w-full select-none items-center px-3 py-2 pr-9 text-left text-sm text-foreground outline-none transition-colors duration-150 hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-45' const isElementOfType = ( child: React.ReactNode, - type: "option" | "optgroup", -): child is React.ReactElement> => React.isValidElement(child) && child.type === type; + type: 'option' | 'optgroup' +): child is React.ReactElement> => React.isValidElement(child) && child.type === type const extractOptions = (children: React.ReactNode): SelectOption[] => { - const options: SelectOption[] = []; + const options: SelectOption[] = [] React.Children.forEach(children, (child) => { - if (!child) return; + if (!child) return if (React.isValidElement(child) && child.type === React.Fragment) { - options.push(...extractOptions(child.props.children)); - return; + options.push(...extractOptions(child.props.children)) + return } - if (isElementOfType(child, "optgroup")) { - options.push(...extractOptions(child.props.children)); - return; + if (isElementOfType(child, 'optgroup')) { + options.push(...extractOptions(child.props.children)) + return } - if (!isElementOfType(child, "option")) { - return; + if (!isElementOfType(child, 'option')) { + return } - const rawValue = child.props.value; + const rawValue = child.props.value if (rawValue == null) { - return; + return } options.push({ value: String(rawValue), label: child.props.children, disabled: Boolean(child.props.disabled), - }); - }); + }) + }) - return options; -}; + return options +} const Select = React.forwardRef( ( @@ -86,102 +85,145 @@ const Select = React.forwardRef( onBlur, onFocus, onKeyDown, - placeholder = "请选择", - "aria-label": ariaLabel, - "aria-describedby": ariaDescribedBy, - "aria-labelledby": ariaLabelledBy, + placeholder = '请选择', + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-labelledby': ariaLabelledBy, }, - ref, + ref ) => { - const options = React.useMemo(() => extractOptions(children), [children]); + const containerRef = React.useRef(null) + const selectedOptionRef = React.useRef(null) + const listboxId = React.useId() + const options = React.useMemo(() => extractOptions(children), [children]) - const normalizedValue = typeof value === "string" || typeof value === "number" ? String(value) : undefined; + const normalizedValue = typeof value === 'string' || typeof value === 'number' ? String(value) : undefined const normalizedDefaultValue = - typeof defaultValue === "string" || typeof defaultValue === "number" ? String(defaultValue) : undefined; + typeof defaultValue === 'string' || typeof defaultValue === 'number' ? String(defaultValue) : undefined + + const isControlled = normalizedValue !== undefined + const [internalValue, setInternalValue] = React.useState(normalizedDefaultValue) + const selectedValue = isControlled ? normalizedValue : internalValue + const selectedOption = options.find((option) => option.value === selectedValue) + const hasValue = typeof selectedValue === 'string' && selectedValue.length > 0 + const [isOpen, setIsOpen] = React.useState(false) + + React.useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, []) + + React.useEffect(() => { + if (disabled) { + setIsOpen(false) + } + }, [disabled]) + + React.useEffect(() => { + if (isOpen) { + selectedOptionRef.current?.scrollIntoView({ block: 'nearest' }) + } + }, [isOpen, selectedValue]) const handleValueChange = React.useCallback( (nextValue: string) => { - if (!onChange) return; + if (!isControlled) { + setInternalValue(nextValue) + } - const syntheticEvent = { - target: { value: nextValue, name }, - currentTarget: { value: nextValue, name }, - } as React.ChangeEvent; + if (onChange) { + const syntheticEvent = { + target: { value: nextValue, name }, + currentTarget: { value: nextValue, name }, + } as React.ChangeEvent - onChange(syntheticEvent); + onChange(syntheticEvent) + } + + setIsOpen(false) }, - [name, onChange], - ); + [isControlled, name, onChange] + ) return ( - - + {name && ( + + )} + + - - - - - + {isOpen && !disabled && ( +
+
+ {options.map((option) => { + const isSelected = option.value === selectedValue - - {options.map((option) => ( - - {option.label} - - - - - ))} - + return ( + + ) + })} +
+
+ )} + + ) + } +) - - - -
-
-
- ); - }, -); +Select.displayName = 'Select' -Select.displayName = "Select"; - -export { Select }; +export { Select } diff --git a/package.json b/package.json index 9551498..3833187 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev:web": "bun run --filter frontend dev", "dev:worker": "wrangler dev --config wrangler.local.jsonc --local --host localhost", "build": "bun run --filter frontend build", - "deploy": "bun run build && wrangler deploy --config wrangler.jsonc --minify", + "deploy": "bun run build && wrangler deploy --config wrangler.jsonc", "cf-typegen": "wrangler types --config wrangler.jsonc --include-env=false" }, "repository": { diff --git a/wrangler.jsonc b/wrangler.jsonc index d847cdc..e9a89bd 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,9 +3,14 @@ "name": "one-api-workers", "main": "src/index.ts", "compatibility_date": "2025-04-28", - "workers_dev": false, - "vars": { - "ADMIN_TOKEN": "CHANGE_ME", + "workers_dev": true, + "vars": {}, + "secrets": { + "required": [ + "ADMIN_TOKEN", + "CF_API_TOKEN", + "CF_ACCOUNT_ID" + ] }, "assets": { "directory": "./public", @@ -16,8 +21,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "one-api", - "database_id": "00000000-0000-0000-0000-000000000000" + "database_name": "one-api-workers", + "database_id": "" } ] }