Sfoglia il codice sorgente

Merge pull request #449 from ding113/fix/user-filter-remove-limit

fix(logs): remove user filter limit for usage logs dropdown
Ding 1 mese fa
parent
commit
a6bb875e67

+ 33 - 0
src/actions/users.ts

@@ -29,6 +29,7 @@ import {
   findUserById,
   findUserList,
   findUserListBatch,
+  searchUsersForFilter as searchUsersForFilterRepository,
   updateUser,
 } from "@/repository/user";
 import type { User, UserDisplay } from "@/types/user";
@@ -295,6 +296,38 @@ export async function getUsers(): Promise<UserDisplay[]> {
   }
 }
 
+export async function searchUsersForFilter(
+  searchTerm?: string
+): Promise<ActionResult<Array<{ id: number; name: string }>>> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session) {
+      return {
+        ok: false,
+        error: tError("UNAUTHORIZED"),
+        errorCode: ERROR_CODES.UNAUTHORIZED,
+      };
+    }
+
+    if (session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    const users = await searchUsersForFilterRepository(searchTerm);
+    return { ok: true, data: users };
+  } catch (error) {
+    logger.error("Failed to search users for filter:", error);
+    const message = error instanceof Error ? error.message : "Failed to search users for filter";
+    return { ok: false, error: message, errorCode: ERROR_CODES.DATABASE_ERROR };
+  }
+}
+
 /**
  * 游标分页获取用户列表(用于无限滚动)
  *

+ 1 - 1
src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx

@@ -116,7 +116,7 @@ function parseHtml(html: string) {
   return window.document;
 }
 
-function getBillingAndPerformanceGrid(document: Document) {
+function getBillingAndPerformanceGrid(document: ReturnType<typeof parseHtml>) {
   return document.querySelector("div.grid.gap-4");
 }
 

+ 77 - 11
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -4,10 +4,11 @@ import { addDays, format, parse } from "date-fns";
 import { Check, ChevronsUpDown, Download } from "lucide-react";
 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 { getKeys } from "@/actions/keys";
 import { exportUsageLogs } from "@/actions/usage-logs";
+import { searchUsersForFilter } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import {
   Command,
@@ -27,9 +28,9 @@ import {
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
+import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { Key } from "@/types/key";
 import type { ProviderDisplay } from "@/types/provider";
-import type { UserDisplay } from "@/types/user";
 import {
   useLazyEndpoints,
   useLazyModels,
@@ -42,10 +43,8 @@ const COMMON_STATUS_CODES: number[] = [200, 400, 401, 429, 500];
 
 interface UsageLogsFiltersProps {
   isAdmin: boolean;
-  users: UserDisplay[];
   providers: ProviderDisplay[];
   initialKeys: Key[];
-  isUsersLoading?: boolean;
   isProvidersLoading?: boolean;
   isKeysLoading?: boolean;
   filters: {
@@ -68,10 +67,8 @@ interface UsageLogsFiltersProps {
 
 export function UsageLogsFilters({
   isAdmin,
-  users,
   providers,
   initialKeys,
-  isUsersLoading = false,
   isProvidersLoading = false,
   isKeysLoading = false,
   filters,
@@ -80,6 +77,14 @@ export function UsageLogsFilters({
 }: UsageLogsFiltersProps) {
   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 - 下拉展开时才加载数据
   const {
     data: models,
@@ -105,7 +110,10 @@ export function UsageLogsFilters({
     return dynamicOnly;
   }, [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(
     () => new Map(providers.map((provider) => [provider.id, provider.name])),
@@ -118,6 +126,61 @@ export function UsageLogsFilters({
   const [userPopoverOpen, setUserPopoverOpen] = 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(() => {
     if (initialKeys.length > 0) {
       setKeys(initialKeys);
@@ -287,7 +350,6 @@ export function UsageLogsFilters({
                   variant="outline"
                   role="combobox"
                   aria-expanded={userPopoverOpen}
-                  disabled={isUsersLoading}
                   type="button"
                   className="w-full justify-between"
                 >
@@ -307,8 +369,12 @@ export function UsageLogsFilters({
                 onWheel={(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">
                     <CommandEmpty>
                       {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>
                         {!localFilters.userId && <Check className="h-4 w-4 text-primary" />}
                       </CommandItem>
-                      {users.map((user) => (
+                      {availableUsers.map((user) => (
                         <CommandItem
                           key={user.id}
                           value={user.name}

+ 0 - 14
src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx

@@ -7,14 +7,12 @@ import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { getKeys } from "@/actions/keys";
 import { getProviders } from "@/actions/providers";
-import { getUsers } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import type { Key } from "@/types/key";
 import type { ProviderDisplay } from "@/types/provider";
 import type { BillingModelSource, SystemSettings } from "@/types/system-config";
-import type { UserDisplay } from "@/types/user";
 import { UsageLogsFilters } from "./usage-logs-filters";
 import { UsageLogsStatsPanel } from "./usage-logs-stats-panel";
 import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table";
@@ -32,7 +30,6 @@ const queryClient = new QueryClient({
 interface UsageLogsViewVirtualizedProps {
   isAdmin: boolean;
   userId: number;
-  users?: UserDisplay[];
   providers?: ProviderDisplay[];
   initialKeys?: Key[];
   searchParams: { [key: string]: string | string[] | undefined };
@@ -51,7 +48,6 @@ async function fetchSystemSettings(): Promise<SystemSettings> {
 function UsageLogsViewContent({
   isAdmin,
   userId,
-  users,
   providers,
   initialKeys,
   searchParams,
@@ -78,13 +74,6 @@ function UsageLogsViewContent({
   const resolvedBillingModelSource =
     billingModelSource ?? systemSettings?.billingModelSource ?? "original";
 
-  const { data: usersData = [], isLoading: isUsersLoading } = useQuery<UserDisplay[]>({
-    queryKey: ["usage-log-users"],
-    queryFn: getUsers,
-    enabled: isAdmin && users === undefined,
-    placeholderData: [],
-  });
-
   const { data: providersData = [], isLoading: isProvidersLoading } = useQuery<ProviderDisplay[]>({
     queryKey: ["usage-log-providers"],
     queryFn: getProviders,
@@ -98,7 +87,6 @@ function UsageLogsViewContent({
     enabled: !isAdmin && initialKeys === undefined,
   });
 
-  const resolvedUsers = users ?? usersData;
   const resolvedProviders = providers ?? providersData;
   const resolvedKeys = initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : []);
 
@@ -212,13 +200,11 @@ function UsageLogsViewContent({
         <CardContent>
           <UsageLogsFilters
             isAdmin={isAdmin}
-            users={resolvedUsers}
             providers={resolvedProviders}
             initialKeys={resolvedKeys}
             filters={filters}
             onChange={handleFilterChange}
             onReset={() => router.push("/dashboard/logs")}
-            isUsersLoading={isUsersLoading}
             isProvidersLoading={isProvidersLoading}
             isKeysLoading={isKeysLoading}
           />

+ 0 - 4
src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx

@@ -13,14 +13,12 @@ import type { UsageLogsResult } from "@/repository/usage-logs";
 import type { Key } from "@/types/key";
 import type { ProviderDisplay } from "@/types/provider";
 import type { BillingModelSource } from "@/types/system-config";
-import type { UserDisplay } from "@/types/user";
 import { UsageLogsFilters } from "./usage-logs-filters";
 import { UsageLogsStatsPanel } from "./usage-logs-stats-panel";
 import { UsageLogsTable } from "./usage-logs-table";
 
 interface UsageLogsViewProps {
   isAdmin: boolean;
-  users: UserDisplay[];
   providers: ProviderDisplay[];
   initialKeys: Key[];
   searchParams: { [key: string]: string | string[] | undefined };
@@ -30,7 +28,6 @@ interface UsageLogsViewProps {
 
 export function UsageLogsView({
   isAdmin,
-  users,
   providers,
   initialKeys,
   searchParams,
@@ -235,7 +232,6 @@ export function UsageLogsView({
         <CardContent>
           <UsageLogsFilters
             isAdmin={isAdmin}
-            users={users}
             providers={providers}
             initialKeys={initialKeys}
             filters={filters}

+ 21 - 0
src/repository/user.ts

@@ -115,6 +115,27 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom
   return result.map(toUser);
 }
 
+export async function searchUsersForFilter(
+  searchTerm?: string
+): Promise<Array<{ id: number; name: string }>> {
+  const conditions = [isNull(users.deletedAt)];
+
+  const trimmedSearchTerm = searchTerm?.trim();
+  if (trimmedSearchTerm) {
+    const pattern = `%${trimmedSearchTerm}%`;
+    conditions.push(sql`${users.name} ILIKE ${pattern}`);
+  }
+
+  return db
+    .select({
+      id: users.id,
+      name: users.name,
+    })
+    .from(users)
+    .where(and(...conditions))
+    .orderBy(sql`CASE WHEN ${users.role} = 'admin' THEN 0 ELSE 1 END`, users.id);
+}
+
 /**
  * Cursor-based pagination (keyset pagination) for user list.
  *

+ 54 - 0
tests/unit/user-repository-search-users-for-filter.test.ts

@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+let capturedIlikePattern: unknown;
+
+vi.mock("drizzle-orm", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("drizzle-orm")>();
+  return {
+    ...actual,
+    sql: (strings: TemplateStringsArray, ...params: unknown[]) => {
+      if (strings.join("").includes("ILIKE")) {
+        capturedIlikePattern = params[1];
+      }
+      return actual.sql(strings, ...params);
+    },
+  };
+});
+
+let resolvedRows: Array<{ id: number; name: string }> = [];
+
+vi.mock("@/drizzle/db", () => {
+  const orderByMock = vi.fn(() => Promise.resolve(resolvedRows));
+  const whereMock = vi.fn(() => ({ orderBy: orderByMock }));
+  const fromMock = vi.fn(() => ({ where: whereMock }));
+  const selectMock = vi.fn(() => ({ from: fromMock }));
+
+  return {
+    db: {
+      select: selectMock,
+    },
+  };
+});
+
+describe("searchUsersForFilter (repository)", () => {
+  beforeEach(() => {
+    capturedIlikePattern = undefined;
+    resolvedRows = [{ id: 1, name: "Alice" }];
+  });
+
+  test("returns all users without limit", async () => {
+    const { searchUsersForFilter } = await import("@/repository/user");
+
+    const result = await searchUsersForFilter();
+
+    expect(result).toEqual(resolvedRows);
+  });
+
+  test("trims search term when building ILIKE pattern", async () => {
+    const { searchUsersForFilter } = await import("@/repository/user");
+
+    await searchUsersForFilter("  bob  ");
+
+    expect(capturedIlikePattern).toBe("%bob%");
+  });
+});

+ 71 - 0
tests/unit/users-action-search-users-for-filter.test.ts

@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+vi.mock("next/cache", () => ({
+  revalidatePath: vi.fn(),
+}));
+
+const getTranslationsMock = vi.fn(async () => (key: string) => key);
+const getLocaleMock = vi.fn(async () => "en");
+vi.mock("next-intl/server", () => ({
+  getTranslations: getTranslationsMock,
+  getLocale: getLocaleMock,
+}));
+
+const searchUsersForFilterRepositoryMock = vi.fn();
+vi.mock("@/repository/user", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/repository/user")>();
+  return {
+    ...actual,
+    searchUsersForFilter: searchUsersForFilterRepositoryMock,
+  };
+});
+
+describe("searchUsersForFilter (action)", () => {
+  beforeEach(() => {
+    getSessionMock.mockReset();
+    searchUsersForFilterRepositoryMock.mockReset();
+  });
+
+  test("returns UNAUTHORIZED when session is missing", async () => {
+    getSessionMock.mockResolvedValue(null);
+
+    const { searchUsersForFilter } = await import("@/actions/users");
+
+    const result = await searchUsersForFilter();
+
+    expect(result.ok).toBe(false);
+    if (!result.ok) {
+      expect(result.errorCode).toBe("UNAUTHORIZED");
+    }
+  });
+
+  test("returns PERMISSION_DENIED for non-admin user", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 123, role: "user" } });
+
+    const { searchUsersForFilter } = await import("@/actions/users");
+
+    const result = await searchUsersForFilter();
+
+    expect(result.ok).toBe(false);
+    if (!result.ok) {
+      expect(result.errorCode).toBe("PERMISSION_DENIED");
+    }
+  });
+
+  test("returns users for admin", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    searchUsersForFilterRepositoryMock.mockResolvedValue([{ id: 1, name: "Alice" }]);
+
+    const { searchUsersForFilter } = await import("@/actions/users");
+
+    const result = await searchUsersForFilter("ali");
+
+    expect(searchUsersForFilterRepositoryMock).toHaveBeenCalledWith("ali");
+    expect(result).toEqual({ ok: true, data: [{ id: 1, name: "Alice" }] });
+  });
+});