fix(frontend): resolve scroll, diagnosis layout, and multi-select regressions (#147)

* fix(frontend): restore modal scroll and bridged multi-select UI

* fix(frontend): collapse multi-select panel and clean diagnosis labels
This commit is contained in:
sagit
2026-02-19 16:10:02 +08:00
committed by GitHub
parent e5c57f81ad
commit 6e5a71f489
8 changed files with 278 additions and 54 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
@@ -4,11 +4,16 @@ import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) { function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />; return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
} }
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) { function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return ( return (
<AccordionPrimitive.Item <AccordionPrimitive.Item
className={cn("border-b", className)} className={cn("border-b", className)}
@@ -27,7 +32,7 @@ function AccordionTrigger({
<AccordionPrimitive.Header className="flex" data-slot="accordion-header"> <AccordionPrimitive.Header className="flex" data-slot="accordion-header">
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
className={cn( className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", "flex flex-1 items-center justify-between gap-3 px-3 py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className, className,
)} )}
data-slot="accordion-trigger" data-slot="accordion-trigger"
+2 -2
View File
@@ -2638,7 +2638,7 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
{result.success && quality ? ( {result.success && quality ? (
<Chip <Chip
className="text-xs" className="text-xs whitespace-nowrap"
color={quality.color as any} color={quality.color as any}
size="sm" size="sm"
variant="flat" variant="flat"
@@ -2803,7 +2803,7 @@ export default function ForwardPage() {
{quality && ( {quality && (
<> <>
<Chip <Chip
className="text-xs" className="text-xs whitespace-nowrap"
color={quality.color as any} color={quality.color as any}
size="sm" size="sm"
variant="flat" variant="flat"
+2 -2
View File
@@ -2118,7 +2118,7 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
{result.success && quality ? ( {result.success && quality ? (
<Chip <Chip
className="text-xs" className="text-xs whitespace-nowrap"
color={quality.color as any} color={quality.color as any}
size="sm" size="sm"
variant="flat" variant="flat"
@@ -2282,7 +2282,7 @@ export default function TunnelPage() {
{quality && ( {quality && (
<> <>
<Chip <Chip
className="text-xs" className="text-xs whitespace-nowrap"
color={quality.color as any} color={quality.color as any}
size="sm" size="sm"
variant="flat" variant="flat"
-1
View File
@@ -626,7 +626,6 @@ export default function UserPage() {
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300", "bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}} }}
placeholder="搜索用户名" placeholder="搜索用户名"
startContent={<SearchIcon className="w-4 h-4 text-default-400" />}
value={searchKeyword} value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)} onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
@@ -8,7 +8,8 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface AccordionProps extends Omit<React.ComponentProps<"div">, "children"> { export interface AccordionProps
extends Omit<React.ComponentProps<"div">, "children"> {
children: React.ReactNode; children: React.ReactNode;
variant?: "bordered" | "light" | "splitted"; variant?: "bordered" | "light" | "splitted";
} }
@@ -21,9 +22,27 @@ export interface AccordionItemProps {
value?: string; value?: string;
} }
export function Accordion({ children, className }: AccordionProps) { function variantClass(variant: AccordionProps["variant"]) {
if (variant === "splitted") {
return "[&>[data-slot=accordion-item]]:mb-3 [&>[data-slot=accordion-item]]:rounded-xl [&>[data-slot=accordion-item]]:border [&>[data-slot=accordion-item]]:border-divider [&>[data-slot=accordion-item]]:border-b-0 [&>[data-slot=accordion-item]]:bg-content1 [&>[data-slot=accordion-item]]:shadow-sm [&>[data-slot=accordion-item]]:overflow-hidden [&>[data-slot=accordion-item]:last-child]:mb-0";
}
if (variant === "bordered") {
return "rounded-xl border border-divider bg-content1 overflow-hidden [&>[data-slot=accordion-item]]:border-b [&>[data-slot=accordion-item]:last-child]:border-b-0";
}
return "";
}
export function Accordion({
children,
className,
variant = "light",
}: AccordionProps) {
return ( return (
<BaseAccordion className={cn("w-full", className)} type="multiple"> <BaseAccordion
className={cn("w-full", variantClass(variant), className)}
type="multiple"
>
{children} {children}
</BaseAccordion> </BaseAccordion>
); );
@@ -1,6 +1,9 @@
import * as React from "react"; import * as React from "react";
import { Dialog, DialogContent as BaseDialogContent } from "@/components/ui/dialog"; import {
Dialog,
DialogContent as BaseDialogContent,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface DisclosureOptions { interface DisclosureOptions {
@@ -35,6 +38,8 @@ export function useDisclosure(options: DisclosureOptions = {}) {
interface ModalContextValue { interface ModalContextValue {
classNames?: Record<string, string>; classNames?: Record<string, string>;
onClose: () => void; onClose: () => void;
scrollBehavior?: "inside" | "outside";
size?: ModalSize;
} }
const ModalContext = React.createContext<ModalContextValue | null>(null); const ModalContext = React.createContext<ModalContextValue | null>(null);
@@ -88,6 +93,8 @@ export function Modal({
isOpen = false, isOpen = false,
onClose, onClose,
onOpenChange, onOpenChange,
scrollBehavior,
size,
}: ModalProps) { }: ModalProps) {
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
onOpenChange?.(open); onOpenChange?.(open);
@@ -101,16 +108,21 @@ export function Modal({
onClose: () => { onClose: () => {
handleOpenChange(false); handleOpenChange(false);
}, },
scrollBehavior,
size,
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
<ModalContext.Provider value={contextValue}>{children}</ModalContext.Provider> <ModalContext.Provider value={contextValue}>
{children}
</ModalContext.Provider>
</Dialog> </Dialog>
); );
} }
interface ModalContentProps extends Omit<React.ComponentProps<"div">, "children"> { interface ModalContentProps
extends Omit<React.ComponentProps<"div">, "children"> {
children: React.ReactNode | ((onClose: () => void) => React.ReactNode); children: React.ReactNode | ((onClose: () => void) => React.ReactNode);
scrollBehavior?: "inside" | "outside"; scrollBehavior?: "inside" | "outside";
size?: ModalSize; size?: ModalSize;
@@ -124,15 +136,24 @@ export function ModalContent({
...props ...props
}: ModalContentProps) { }: ModalContentProps) {
const context = useModalContext(); const context = useModalContext();
const resolvedScrollBehavior = scrollBehavior ?? context?.scrollBehavior;
const resolvedSize = size ?? context?.size;
const renderedChildren = const renderedChildren =
typeof children === "function" ? children(() => context?.onClose()) : children; typeof children === "function"
? children(() => context?.onClose())
: children;
return ( return (
<BaseDialogContent <BaseDialogContent
className={cn( className={cn(
mapSize(size), mapSize(resolvedSize),
context?.classNames?.base, context?.classNames?.base,
scrollBehavior === "outside" ? "max-h-[90vh] overflow-y-auto" : "", resolvedScrollBehavior === "outside"
? "max-h-[90vh] overflow-y-auto"
: "",
resolvedScrollBehavior === "inside"
? "max-h-[90vh] flex flex-col overflow-hidden [&>[data-slot=modal-body]]:min-h-0 [&>[data-slot=modal-body]]:flex-1 [&>[data-slot=modal-body]]:overflow-y-auto"
: "",
className, className,
)} )}
showCloseButton={false} showCloseButton={false}
@@ -143,18 +164,54 @@ export function ModalContent({
); );
} }
export function ModalHeader({ className, ...props }: React.ComponentProps<"div">) { export function ModalHeader({
return <div className={cn("text-lg font-semibold", className)} {...props} />; className,
} ...props
}: React.ComponentProps<"div">) {
const context = useModalContext();
export function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("space-y-4", className)} {...props} />;
}
export function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
className={cn("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} className={cn(
"text-lg font-semibold",
context?.classNames?.header,
className,
)}
data-slot="modal-header"
{...props}
/>
);
}
export function ModalBody({
className,
...props
}: React.ComponentProps<"div">) {
const context = useModalContext();
return (
<div
className={cn("space-y-4", context?.classNames?.body, className)}
data-slot="modal-body"
{...props}
/>
);
}
export function ModalFooter({
className,
...props
}: React.ComponentProps<"div">) {
const context = useModalContext();
return (
<div
className={cn(
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
context?.classNames?.footer,
className,
)}
data-slot="modal-footer"
{...props} {...props}
/> />
); );
+168 -28
View File
@@ -1,9 +1,11 @@
import * as React from "react"; import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { FieldContainer, extractText, type FieldMetaProps } from "./shared"; import { FieldContainer, extractText, type FieldMetaProps } from "./shared";
import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
type SelectionMode = "single" | "multiple"; type SelectionMode = "single" | "multiple";
type SelectionValue = Iterable<React.Key> | Set<React.Key> | Array<React.Key>; type SelectionValue = Iterable<React.Key> | Set<React.Key> | Array<React.Key>;
@@ -131,6 +133,17 @@ function sizeClass(size: SelectProps["size"]) {
return "h-9 text-sm"; return "h-9 text-sm";
} }
function textSizeClass(size: SelectProps["size"]) {
if (size === "sm") {
return "text-xs";
}
if (size === "lg") {
return "text-base";
}
return "text-sm";
}
export function Select<T>({ export function Select<T>({
children, children,
className, className,
@@ -152,12 +165,41 @@ export function Select<T>({
size, size,
}: SelectProps<T>) { }: SelectProps<T>) {
const generatedId = React.useId(); const generatedId = React.useId();
const options = React.useMemo(() => getOptions(children, items), [children, items]); const options = React.useMemo(
() => getOptions(children, items),
[children, items],
);
const [isExpanded, setIsExpanded] = React.useState(false);
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]); const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]); const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
const selectedArray = Array.from(selected); const selectedArray = Array.from(selected);
const singleValue = selectedArray[0] ?? ""; const singleValue = selectedArray[0] ?? "";
const selectedText =
selectedArray.length > 0
? options
.filter((option) => selected.has(option.key))
.map((option) => option.label)
.join("、")
: (placeholder ?? "请选择");
const updateMultipleSelection = (key: string, checked?: boolean) => {
if (isDisabled || disabled.has(key)) {
return;
}
const next = new Set(selected);
const shouldSelect =
typeof checked === "boolean" ? checked : !next.has(key);
if (shouldSelect) {
next.add(key);
} else {
next.delete(key);
}
onSelectionChange?.(next);
};
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChange?.(event); onChange?.(event);
@@ -167,7 +209,9 @@ export function Select<T>({
} }
if (selectionMode === "multiple") { if (selectionMode === "multiple") {
const values = Array.from(event.target.selectedOptions).map((option) => option.value); const values = Array.from(event.target.selectedOptions).map(
(option) => option.value,
);
onSelectionChange(new Set(values)); onSelectionChange(new Set(values));
@@ -193,31 +237,127 @@ export function Select<T>({
isRequired={isRequired} isRequired={isRequired}
label={label} label={label}
> >
<select {selectionMode === "multiple" ? (
className={cn( <div
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", className={cn(
sizeClass(size), "w-full rounded-md border border-input bg-background shadow-sm",
selectionMode === "multiple" ? "min-h-32" : "", isDisabled ? "cursor-not-allowed opacity-60" : "",
classNames?.trigger, className,
className, )}
)} id={generatedId}
disabled={isDisabled} >
id={generatedId} <button
multiple={selectionMode === "multiple"} aria-controls={`${generatedId}-listbox`}
required={isRequired} aria-expanded={isExpanded}
value={selectionMode === "multiple" ? selectedArray : singleValue} className={cn(
onClick={onClick} "flex w-full items-center gap-2 px-3 py-2 text-left",
onChange={handleChange} classNames?.trigger,
> )}
{selectionMode === "single" ? ( disabled={isDisabled}
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
>
<span
className={cn(
"block flex-1 truncate",
textSizeClass(size),
selectedArray.length > 0 ? "text-foreground" : "text-default-500",
)}
title={selectedText}
>
{selectedText}
</span>
<ChevronDownIcon
className={cn(
"h-4 w-4 flex-shrink-0 text-default-500 transition-transform",
isExpanded ? "rotate-180" : "rotate-0",
)}
/>
</button>
{isExpanded ? (
<div
className="max-h-56 space-y-1 overflow-y-auto border-t border-divider p-2"
id={`${generatedId}-listbox`}
role="listbox"
>
{options.length === 0 ? (
<div
className={cn(
"px-2 py-1 text-default-500",
textSizeClass(size),
)}
>
暂无可选项
</div>
) : (
options.map((option) => {
const optionDisabled = isDisabled || disabled.has(option.key);
return (
<div
key={option.key}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
optionDisabled
? "cursor-not-allowed opacity-60"
: "hover:bg-default-100",
)}
>
<BaseCheckbox
checked={selected.has(option.key)}
disabled={optionDisabled}
onCheckedChange={(value) =>
updateMultipleSelection(option.key, value === true)
}
/>
<button
className={cn(
"min-w-0 flex-1 truncate text-left text-foreground",
textSizeClass(size),
optionDisabled
? "cursor-not-allowed"
: "cursor-pointer",
)}
disabled={optionDisabled}
type="button"
onClick={() => updateMultipleSelection(option.key)}
>
{option.label}
</button>
</div>
);
})
)}
</div>
) : null}
</div>
) : (
<select
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClass(size),
classNames?.trigger,
className,
)}
disabled={isDisabled}
id={generatedId}
required={isRequired}
value={singleValue}
onChange={handleChange}
onClick={onClick}
>
<option value="">{placeholder ?? "请选择"}</option> <option value="">{placeholder ?? "请选择"}</option>
) : null} {options.map((option) => (
{options.map((option) => ( <option
<option key={option.key} disabled={disabled.has(option.key)} value={option.key}> key={option.key}
{option.label} disabled={disabled.has(option.key)}
</option> value={option.key}
))} >
</select> {option.label}
</option>
))}
</select>
)}
</FieldContainer> </FieldContainer>
); );
} }