|
@@ -4,10 +4,11 @@ import { addDays, format, parse } from "date-fns";
|
|
|
import { Check, ChevronsUpDown, Download } from "lucide-react";
|
|
import { Check, ChevronsUpDown, Download } from "lucide-react";
|
|
|
import { useTranslations } from "next-intl";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
|
|
|
|
-import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
import { toast } from "sonner";
|
|
import { toast } from "sonner";
|
|
|
import { getKeys } from "@/actions/keys";
|
|
import { getKeys } from "@/actions/keys";
|
|
|
import { exportUsageLogs } from "@/actions/usage-logs";
|
|
import { exportUsageLogs } from "@/actions/usage-logs";
|
|
|
|
|
+import { searchUsersForFilter } from "@/actions/users";
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Button } from "@/components/ui/button";
|
|
|
import {
|
|
import {
|
|
|
Command,
|
|
Command,
|
|
@@ -27,9 +28,9 @@ import {
|
|
|
SelectTrigger,
|
|
SelectTrigger,
|
|
|
SelectValue,
|
|
SelectValue,
|
|
|
} from "@/components/ui/select";
|
|
} from "@/components/ui/select";
|
|
|
|
|
+import { useDebounce } from "@/lib/hooks/use-debounce";
|
|
|
import type { Key } from "@/types/key";
|
|
import type { Key } from "@/types/key";
|
|
|
import type { ProviderDisplay } from "@/types/provider";
|
|
import type { ProviderDisplay } from "@/types/provider";
|
|
|
-import type { UserDisplay } from "@/types/user";
|
|
|
|
|
import {
|
|
import {
|
|
|
useLazyEndpoints,
|
|
useLazyEndpoints,
|
|
|
useLazyModels,
|
|
useLazyModels,
|
|
@@ -42,10 +43,8 @@ const COMMON_STATUS_CODES: number[] = [200, 400, 401, 429, 500];
|
|
|
|
|
|
|
|
interface UsageLogsFiltersProps {
|
|
interface UsageLogsFiltersProps {
|
|
|
isAdmin: boolean;
|
|
isAdmin: boolean;
|
|
|
- users: UserDisplay[];
|
|
|
|
|
providers: ProviderDisplay[];
|
|
providers: ProviderDisplay[];
|
|
|
initialKeys: Key[];
|
|
initialKeys: Key[];
|
|
|
- isUsersLoading?: boolean;
|
|
|
|
|
isProvidersLoading?: boolean;
|
|
isProvidersLoading?: boolean;
|
|
|
isKeysLoading?: boolean;
|
|
isKeysLoading?: boolean;
|
|
|
filters: {
|
|
filters: {
|
|
@@ -68,10 +67,8 @@ interface UsageLogsFiltersProps {
|
|
|
|
|
|
|
|
export function UsageLogsFilters({
|
|
export function UsageLogsFilters({
|
|
|
isAdmin,
|
|
isAdmin,
|
|
|
- users,
|
|
|
|
|
providers,
|
|
providers,
|
|
|
initialKeys,
|
|
initialKeys,
|
|
|
- isUsersLoading = false,
|
|
|
|
|
isProvidersLoading = false,
|
|
isProvidersLoading = false,
|
|
|
isKeysLoading = false,
|
|
isKeysLoading = false,
|
|
|
filters,
|
|
filters,
|
|
@@ -80,6 +77,14 @@ export function UsageLogsFilters({
|
|
|
}: UsageLogsFiltersProps) {
|
|
}: UsageLogsFiltersProps) {
|
|
|
const t = useTranslations("dashboard");
|
|
const t = useTranslations("dashboard");
|
|
|
|
|
|
|
|
|
|
+ const [isUsersLoading, setIsUsersLoading] = useState(false);
|
|
|
|
|
+ const [userSearchTerm, setUserSearchTerm] = useState("");
|
|
|
|
|
+ const debouncedUserSearchTerm = useDebounce(userSearchTerm, 300);
|
|
|
|
|
+ const [availableUsers, setAvailableUsers] = useState<Array<{ id: number; name: string }>>([]);
|
|
|
|
|
+ const userSearchRequestIdRef = useRef(0);
|
|
|
|
|
+ const lastLoadedUserSearchTermRef = useRef<string | undefined>(undefined);
|
|
|
|
|
+ const isMountedRef = useRef(true);
|
|
|
|
|
+
|
|
|
// 惰性加载 hooks - 下拉展开时才加载数据
|
|
// 惰性加载 hooks - 下拉展开时才加载数据
|
|
|
const {
|
|
const {
|
|
|
data: models,
|
|
data: models,
|
|
@@ -105,7 +110,10 @@ export function UsageLogsFilters({
|
|
|
return dynamicOnly;
|
|
return dynamicOnly;
|
|
|
}, [dynamicStatusCodes]);
|
|
}, [dynamicStatusCodes]);
|
|
|
|
|
|
|
|
- const userMap = useMemo(() => new Map(users.map((user) => [user.id, user.name])), [users]);
|
|
|
|
|
|
|
+ const userMap = useMemo(
|
|
|
|
|
+ () => new Map(availableUsers.map((user) => [user.id, user.name])),
|
|
|
|
|
+ [availableUsers]
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
const providerMap = useMemo(
|
|
const providerMap = useMemo(
|
|
|
() => new Map(providers.map((provider) => [provider.id, provider.name])),
|
|
() => new Map(providers.map((provider) => [provider.id, provider.name])),
|
|
@@ -118,6 +126,61 @@ export function UsageLogsFilters({
|
|
|
const [userPopoverOpen, setUserPopoverOpen] = useState(false);
|
|
const [userPopoverOpen, setUserPopoverOpen] = useState(false);
|
|
|
const [providerPopoverOpen, setProviderPopoverOpen] = useState(false);
|
|
const [providerPopoverOpen, setProviderPopoverOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ isMountedRef.current = true;
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ isMountedRef.current = false;
|
|
|
|
|
+ };
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const loadUsersForFilter = useCallback(async (term?: string) => {
|
|
|
|
|
+ const requestId = ++userSearchRequestIdRef.current;
|
|
|
|
|
+ setIsUsersLoading(true);
|
|
|
|
|
+ lastLoadedUserSearchTermRef.current = term;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await searchUsersForFilter(term);
|
|
|
|
|
+ if (!isMountedRef.current || requestId !== userSearchRequestIdRef.current) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (result.ok) {
|
|
|
|
|
+ setAvailableUsers(result.data);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error("Failed to load users for filter:", result.error);
|
|
|
|
|
+ setAvailableUsers([]);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (!isMountedRef.current || requestId !== userSearchRequestIdRef.current) return;
|
|
|
|
|
+
|
|
|
|
|
+ console.error("Failed to load users for filter:", error);
|
|
|
|
|
+ setAvailableUsers([]);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (isMountedRef.current && requestId === userSearchRequestIdRef.current) {
|
|
|
|
|
+ setIsUsersLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isAdmin) return;
|
|
|
|
|
+ void loadUsersForFilter(undefined);
|
|
|
|
|
+ }, [isAdmin, loadUsersForFilter]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isAdmin || !userPopoverOpen) return;
|
|
|
|
|
+
|
|
|
|
|
+ const term = debouncedUserSearchTerm.trim() || undefined;
|
|
|
|
|
+ if (term === lastLoadedUserSearchTermRef.current) return;
|
|
|
|
|
+
|
|
|
|
|
+ void loadUsersForFilter(term);
|
|
|
|
|
+ }, [isAdmin, userPopoverOpen, debouncedUserSearchTerm, loadUsersForFilter]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isAdmin) return;
|
|
|
|
|
+ if (!userPopoverOpen) {
|
|
|
|
|
+ setUserSearchTerm("");
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [isAdmin, userPopoverOpen]);
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (initialKeys.length > 0) {
|
|
if (initialKeys.length > 0) {
|
|
|
setKeys(initialKeys);
|
|
setKeys(initialKeys);
|
|
@@ -287,7 +350,6 @@ export function UsageLogsFilters({
|
|
|
variant="outline"
|
|
variant="outline"
|
|
|
role="combobox"
|
|
role="combobox"
|
|
|
aria-expanded={userPopoverOpen}
|
|
aria-expanded={userPopoverOpen}
|
|
|
- disabled={isUsersLoading}
|
|
|
|
|
type="button"
|
|
type="button"
|
|
|
className="w-full justify-between"
|
|
className="w-full justify-between"
|
|
|
>
|
|
>
|
|
@@ -307,8 +369,12 @@ export function UsageLogsFilters({
|
|
|
onWheel={(e) => e.stopPropagation()}
|
|
onWheel={(e) => e.stopPropagation()}
|
|
|
onTouchMove={(e) => e.stopPropagation()}
|
|
onTouchMove={(e) => e.stopPropagation()}
|
|
|
>
|
|
>
|
|
|
- <Command shouldFilter={true}>
|
|
|
|
|
- <CommandInput placeholder={t("logs.filters.searchUser")} />
|
|
|
|
|
|
|
+ <Command shouldFilter={false}>
|
|
|
|
|
+ <CommandInput
|
|
|
|
|
+ placeholder={t("logs.filters.searchUser")}
|
|
|
|
|
+ value={userSearchTerm}
|
|
|
|
|
+ onValueChange={(value) => setUserSearchTerm(value)}
|
|
|
|
|
+ />
|
|
|
<CommandList className="max-h-[250px] overflow-y-auto">
|
|
<CommandList className="max-h-[250px] overflow-y-auto">
|
|
|
<CommandEmpty>
|
|
<CommandEmpty>
|
|
|
{isUsersLoading ? t("logs.stats.loading") : t("logs.filters.noUserFound")}
|
|
{isUsersLoading ? t("logs.stats.loading") : t("logs.filters.noUserFound")}
|
|
@@ -325,7 +391,7 @@ export function UsageLogsFilters({
|
|
|
<span className="flex-1">{t("logs.filters.allUsers")}</span>
|
|
<span className="flex-1">{t("logs.filters.allUsers")}</span>
|
|
|
{!localFilters.userId && <Check className="h-4 w-4 text-primary" />}
|
|
{!localFilters.userId && <Check className="h-4 w-4 text-primary" />}
|
|
|
</CommandItem>
|
|
</CommandItem>
|
|
|
- {users.map((user) => (
|
|
|
|
|
|
|
+ {availableUsers.map((user) => (
|
|
|
<CommandItem
|
|
<CommandItem
|
|
|
key={user.id}
|
|
key={user.id}
|
|
|
value={user.name}
|
|
value={user.name}
|