247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface ClientInfo {
|
|
id: string;
|
|
name: string;
|
|
/** 行业/类型标签 */
|
|
type?: string;
|
|
/** 头像 URL 或首字母 */
|
|
avatar?: string;
|
|
/** 是否激活 */
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export interface ClientSwitcherProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
clients: ClientInfo[];
|
|
currentClientId?: string;
|
|
onClientChange?: (client: ClientInfo) => void;
|
|
placeholder?: string;
|
|
}
|
|
|
|
const ClientAvatar = ({ client, size = "sm" }: { client: ClientInfo; size?: "sm" | "md" }) => {
|
|
const sizeClass = size === "md" ? "h-8 w-8 text-sm" : "h-6 w-6 text-xs";
|
|
if (client.avatar && client.avatar.startsWith("http")) {
|
|
return (
|
|
<img
|
|
src={client.avatar}
|
|
alt={client.name}
|
|
className={cn("shrink-0 rounded-lg object-cover", sizeClass)}
|
|
/>
|
|
);
|
|
}
|
|
// generate color from name
|
|
const colors = [
|
|
"bg-primary/15 text-primary",
|
|
"bg-accent/15 text-accent",
|
|
"bg-purple-100 text-purple-600",
|
|
"bg-blue-100 text-blue-600",
|
|
"bg-pink-100 text-pink-600",
|
|
"bg-indigo-100 text-indigo-600",
|
|
];
|
|
const colorIdx = client.name.charCodeAt(0) % colors.length;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"shrink-0 flex items-center justify-center rounded-lg font-bold",
|
|
sizeClass,
|
|
colors[colorIdx]
|
|
)}
|
|
>
|
|
{client.avatar ?? client.name.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ClientSwitcher = React.forwardRef<HTMLDivElement, ClientSwitcherProps>(
|
|
(
|
|
{
|
|
className,
|
|
clients,
|
|
currentClientId,
|
|
onClientChange,
|
|
placeholder = "选择客户",
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [search, setSearch] = React.useState("");
|
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const currentClient = clients.find((c) => c.id === currentClientId);
|
|
|
|
const filtered = React.useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return clients;
|
|
return clients.filter(
|
|
(c) =>
|
|
c.name.toLowerCase().includes(q) ||
|
|
c.type?.toLowerCase().includes(q)
|
|
);
|
|
}, [clients, search]);
|
|
|
|
// close on outside click
|
|
React.useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
setSearch("");
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, []);
|
|
|
|
// focus input when open
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
}
|
|
}, [open]);
|
|
|
|
const handleSelect = (client: ClientInfo) => {
|
|
onClientChange?.(client);
|
|
setOpen(false);
|
|
setSearch("");
|
|
};
|
|
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} {...props}>
|
|
{/* Trigger */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((p) => !p)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-lg border border-border bg-background px-3 py-2 text-sm transition-all duration-200",
|
|
"hover:border-primary/40 hover:bg-muted/30",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
open && "border-primary/50 ring-2 ring-ring/20"
|
|
)}
|
|
>
|
|
{currentClient ? (
|
|
<>
|
|
<ClientAvatar client={currentClient} size="sm" />
|
|
<div className="flex-1 min-w-0 text-left">
|
|
<p className="text-sm font-semibold text-foreground truncate leading-none">
|
|
{currentClient.name}
|
|
</p>
|
|
{currentClient.type && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate leading-none">
|
|
{currentClient.type}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className="flex-1 text-left text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
<svg
|
|
className={cn(
|
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
|
open && "rotate-180"
|
|
)}
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
>
|
|
<path
|
|
d="M4 6L8 10L12 6"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{open && (
|
|
<div
|
|
ref={dropdownRef}
|
|
className={cn(
|
|
"absolute left-0 top-full z-50 mt-1.5 w-full min-w-[220px] overflow-hidden rounded-xl border border-border bg-background shadow-lg",
|
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
|
)}
|
|
>
|
|
{/* Search */}
|
|
<div className="p-2 border-b border-border">
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted px-3 py-1.5">
|
|
<svg className="h-3.5 w-3.5 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
|
|
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="搜索客户..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
{search && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearch("")}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
|
|
<path d="M4 4L10 10M10 4L4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="max-h-64 overflow-y-auto geo-scrollbar p-1.5">
|
|
{filtered.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
未找到匹配的客户
|
|
</div>
|
|
) : (
|
|
filtered.map((client) => {
|
|
const isSelected = client.id === currentClientId;
|
|
return (
|
|
<button
|
|
key={client.id}
|
|
type="button"
|
|
onClick={() => handleSelect(client)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition-colors duration-150",
|
|
isSelected
|
|
? "bg-primary/8 text-primary"
|
|
: "text-foreground hover:bg-muted"
|
|
)}
|
|
>
|
|
<ClientAvatar client={client} size="sm" />
|
|
<div className="flex-1 min-w-0 text-left">
|
|
<p className="font-medium truncate leading-none">{client.name}</p>
|
|
{client.type && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate leading-none">
|
|
{client.type}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isSelected && (
|
|
<svg className="h-4 w-4 shrink-0 text-primary" viewBox="0 0 16 16" fill="none">
|
|
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
ClientSwitcher.displayName = "ClientSwitcher";
|
|
|
|
export { ClientSwitcher };
|