Просмотр исходного кода

fix: 优化用户筛选与排序体验

NightYu 1 месяц назад
Родитель
Сommit
dc4291cb82

+ 19 - 0
messages/en/dashboard.json

@@ -1045,6 +1045,25 @@
       "allTags": "All Tags",
       "keyGroupFilter": "Key Group",
       "allKeyGroups": "All Key Groups",
+      "sortBy": "Sort by",
+      "sortOrder": "Sort order",
+      "sortByName": "Name",
+      "sortByTags": "Tags",
+      "sortByExpiresAt": "Expires at",
+      "sortByLimit5h": "5h limit",
+      "sortByLimitDaily": "Daily limit",
+      "sortByLimitWeekly": "Weekly limit",
+      "sortByLimitMonthly": "Monthly limit",
+      "sortByCreatedAt": "Created at",
+      "ascending": "Ascending",
+      "descending": "Descending",
+      "statusFilter": "Status",
+      "allStatus": "All statuses",
+      "statusActive": "Active",
+      "statusExpired": "Expired",
+      "statusExpiringSoon": "Expiring soon",
+      "statusEnabled": "Enabled",
+      "statusDisabled": "Disabled",
       "createUser": "Create User",
       "createKey": "Create Key"
     },

+ 19 - 0
messages/ja/dashboard.json

@@ -1021,6 +1021,25 @@
       "allTags": "すべてのタグ",
       "keyGroupFilter": "キーグループ",
       "allKeyGroups": "すべてのキーグループ",
+      "sortBy": "並び替え",
+      "sortOrder": "並び順",
+      "sortByName": "名前",
+      "sortByTags": "タグ",
+      "sortByExpiresAt": "有効期限",
+      "sortByLimit5h": "5時間上限",
+      "sortByLimitDaily": "日次上限",
+      "sortByLimitWeekly": "週次上限",
+      "sortByLimitMonthly": "月次上限",
+      "sortByCreatedAt": "作成日時",
+      "ascending": "昇順",
+      "descending": "降順",
+      "statusFilter": "ステータス",
+      "allStatus": "すべての状態",
+      "statusActive": "有効",
+      "statusExpired": "期限切れ",
+      "statusExpiringSoon": "まもなく期限切れ",
+      "statusEnabled": "有効化",
+      "statusDisabled": "無効化",
       "createUser": "ユーザーを作成"
     }
   },

+ 19 - 0
messages/ru/dashboard.json

@@ -1023,6 +1023,25 @@
       "allTags": "Все теги",
       "keyGroupFilter": "Группа ключей",
       "allKeyGroups": "Все группы ключей",
+      "sortBy": "Сортировка",
+      "sortOrder": "Порядок",
+      "sortByName": "Имя",
+      "sortByTags": "Теги",
+      "sortByExpiresAt": "Срок действия",
+      "sortByLimit5h": "Лимит 5ч",
+      "sortByLimitDaily": "Дневной лимит",
+      "sortByLimitWeekly": "Недельный лимит",
+      "sortByLimitMonthly": "Месячный лимит",
+      "sortByCreatedAt": "Дата создания",
+      "ascending": "По возрастанию",
+      "descending": "По убыванию",
+      "statusFilter": "Статус",
+      "allStatus": "Все статусы",
+      "statusActive": "Активен",
+      "statusExpired": "Истек",
+      "statusExpiringSoon": "Скоро истечет",
+      "statusEnabled": "Включен",
+      "statusDisabled": "Отключен",
       "createUser": "Создать пользователя",
       "createKey": "Создать ключ"
     },

+ 19 - 0
messages/zh-CN/dashboard.json

@@ -934,6 +934,25 @@
       "allTags": "所有标签",
       "keyGroupFilter": "密钥分组",
       "allKeyGroups": "所有密钥分组",
+      "sortBy": "排序方式",
+      "sortOrder": "排序顺序",
+      "sortByName": "按名称",
+      "sortByTags": "按标签",
+      "sortByExpiresAt": "按过期时间",
+      "sortByLimit5h": "按5小时限额",
+      "sortByLimitDaily": "按每日限额",
+      "sortByLimitWeekly": "按周限额",
+      "sortByLimitMonthly": "按月限额",
+      "sortByCreatedAt": "按创建时间",
+      "ascending": "升序",
+      "descending": "降序",
+      "statusFilter": "状态筛选",
+      "allStatus": "全部状态",
+      "statusActive": "正常",
+      "statusExpired": "已过期",
+      "statusExpiringSoon": "即将过期",
+      "statusEnabled": "已启用",
+      "statusDisabled": "已禁用",
       "createUser": "创建用户",
       "createKey": "创建 Key"
     },

+ 19 - 0
messages/zh-TW/dashboard.json

@@ -1024,6 +1024,25 @@
       "allTags": "所有標籤",
       "keyGroupFilter": "金鑰分組",
       "allKeyGroups": "所有金鑰分組",
+      "sortBy": "排序方式",
+      "sortOrder": "排序順序",
+      "sortByName": "按名稱",
+      "sortByTags": "按標籤",
+      "sortByExpiresAt": "按過期時間",
+      "sortByLimit5h": "按5小時限額",
+      "sortByLimitDaily": "按每日限額",
+      "sortByLimitWeekly": "按週限額",
+      "sortByLimitMonthly": "按月限額",
+      "sortByCreatedAt": "按建立時間",
+      "ascending": "升序",
+      "descending": "降序",
+      "statusFilter": "狀態篩選",
+      "allStatus": "全部狀態",
+      "statusActive": "正常",
+      "statusExpired": "已過期",
+      "statusExpiringSoon": "即將過期",
+      "statusEnabled": "已啟用",
+      "statusDisabled": "已禁用",
       "createUser": "建立使用者",
       "createKey": "建立 Key"
     },

+ 95 - 6
src/actions/users.ts

@@ -29,6 +29,8 @@ import {
   findUserById,
   findUserList,
   findUserListBatch,
+  getAllUserProviderGroups as getAllUserProviderGroupsRepository,
+  getAllUserTags as getAllUserTagsRepository,
   searchUsersForFilter as searchUsersForFilterRepository,
   updateUser,
 } from "@/repository/user";
@@ -36,16 +38,27 @@ import type { User, UserDisplay } from "@/types/user";
 import type { ActionResult } from "./types";
 
 export interface GetUsersBatchParams {
-  cursor?: { id: number; createdAt: string };
+  cursor?: number;
   limit?: number;
   searchTerm?: string;
-  tagFilter?: string;
-  keyGroupFilter?: string;
+  tagFilters?: string[];
+  keyGroupFilters?: string[];
+  statusFilter?: "all" | "active" | "expired" | "expiringSoon" | "enabled" | "disabled";
+  sortBy?:
+    | "name"
+    | "tags"
+    | "expiresAt"
+    | "limit5hUsd"
+    | "limitDailyUsd"
+    | "limitWeeklyUsd"
+    | "limitMonthlyUsd"
+    | "createdAt";
+  sortOrder?: "asc" | "desc";
 }
 
 export interface GetUsersBatchResult {
   users: UserDisplay[];
-  nextCursor: { id: number; createdAt: string } | null;
+  nextCursor: number | null;
   hasMore: boolean;
 }
 
@@ -328,6 +341,79 @@ export async function searchUsersForFilter(
   }
 }
 
+/**
+ * 获取所有用户标签(用于标签筛选下拉框)
+ * 返回所有用户的标签,不受当前筛选条件影响
+ *
+ * 注意:仅管理员可用。
+ */
+export async function getAllUserTags(): Promise<ActionResult<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 tags = await getAllUserTagsRepository();
+    return { ok: true, data: tags };
+  } catch (error) {
+    logger.error("Failed to get all user tags:", error);
+    const message = error instanceof Error ? error.message : "Failed to get all user tags";
+    return { ok: false, error: message, errorCode: ERROR_CODES.DATABASE_ERROR };
+  }
+}
+
+/**
+ * 获取所有用户密钥分组(用于密钥分组筛选下拉框)
+ * 返回所有用户的分组,不受当前筛选条件影响
+ *
+ * 注意:仅管理员可用。
+ */
+export async function getAllUserKeyGroups(): Promise<ActionResult<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 groups = await getAllUserProviderGroupsRepository();
+    return { ok: true, data: groups };
+  } catch (error) {
+    logger.error("Failed to get all user provider groups:", error);
+    const message =
+      error instanceof Error ? error.message : "Failed to get all user provider groups";
+    return { ok: false, error: message, errorCode: ERROR_CODES.DATABASE_ERROR };
+  }
+}
+
 /**
  * 游标分页获取用户列表(用于无限滚动)
  *
@@ -362,8 +448,11 @@ export async function getUsersBatch(
       cursor: params.cursor,
       limit: params.limit,
       searchTerm: params.searchTerm,
-      tagFilter: params.tagFilter,
-      keyGroupFilter: params.keyGroupFilter,
+      tagFilters: params.tagFilters,
+      keyGroupFilters: params.keyGroupFilters,
+      statusFilter: params.statusFilter,
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder,
     });
 
     if (users.length === 0) {

+ 231 - 65
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -1,11 +1,15 @@
 "use client";
 
-import { QueryClient, QueryClientProvider, useInfiniteQuery } from "@tanstack/react-query";
+import {
+  QueryClient,
+  QueryClientProvider,
+  useInfiniteQuery,
+  useQuery,
+} from "@tanstack/react-query";
 import { Key, Loader2, Plus, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useState } from "react";
-import { getUsers, getUsersBatch } from "@/actions/users";
-import { Badge } from "@/components/ui/badge";
+import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import {
@@ -16,6 +20,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Skeleton } from "@/components/ui/skeleton";
+import { TagInput } from "@/components/ui/tag-input";
 import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { User, UserDisplay } from "@/types/user";
 import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
@@ -64,24 +69,62 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
   const tCommon = useTranslations("common");
   const isAdmin = currentUser.role === "admin";
   const [searchTerm, setSearchTerm] = useState("");
-  const [tagFilter, setTagFilter] = useState("all");
-  const [keyGroupFilter, setKeyGroupFilter] = useState("all");
+  const [tagFilters, setTagFilters] = useState<string[]>([]);
+  const [pendingTagFilters, setPendingTagFilters] = useState<string[]>([]);
+  const [keyGroupFilters, setKeyGroupFilters] = useState<string[]>([]);
+  const [pendingKeyGroupFilters, setPendingKeyGroupFilters] = useState<string[]>([]);
+  const [statusFilter, setStatusFilter] = useState<
+    "all" | "active" | "expired" | "expiringSoon" | "enabled" | "disabled"
+  >("all");
+  const [sortBy, setSortBy] = useState<
+    | "name"
+    | "tags"
+    | "expiresAt"
+    | "limit5hUsd"
+    | "limitDailyUsd"
+    | "limitWeeklyUsd"
+    | "limitMonthlyUsd"
+    | "createdAt"
+  >("createdAt");
+  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
 
   // Debounce search term to avoid frequent API requests
   const debouncedSearchTerm = useDebounce(searchTerm, 300);
+  const debouncedPendingTagsKey = useDebounce(pendingTagFilters.slice().sort().join("|"), 300);
+  const debouncedPendingKeyGroupsKey = useDebounce(
+    pendingKeyGroupFilters.slice().sort().join("|"),
+    300
+  );
 
   // Use debounced value for API queries, raw value for UI highlighting
   const resolvedSearchTerm = debouncedSearchTerm.trim() ? debouncedSearchTerm.trim() : undefined;
-  const resolvedTagFilter = tagFilter === "all" ? undefined : tagFilter;
-  const resolvedKeyGroupFilter = keyGroupFilter === "all" ? undefined : keyGroupFilter;
+  const resolvedTagFilters = tagFilters.length > 0 ? tagFilters : undefined;
+  const resolvedKeyGroupFilters = keyGroupFilters.length > 0 ? keyGroupFilters : undefined;
+  const resolvedStatusFilter = statusFilter === "all" ? undefined : statusFilter;
 
   // Stable queryKey for non-admin users to avoid unnecessary cache entries
   const queryKey = useMemo(
     () =>
       isAdmin
-        ? ["users", resolvedSearchTerm, resolvedTagFilter, resolvedKeyGroupFilter]
+        ? [
+            "users",
+            resolvedSearchTerm,
+            resolvedTagFilters,
+            resolvedKeyGroupFilters,
+            resolvedStatusFilter,
+            sortBy,
+            sortOrder,
+          ]
         : ["users", "self"],
-    [isAdmin, resolvedSearchTerm, resolvedTagFilter, resolvedKeyGroupFilter]
+    [
+      isAdmin,
+      resolvedSearchTerm,
+      resolvedTagFilters,
+      resolvedKeyGroupFilters,
+      resolvedStatusFilter,
+      sortBy,
+      sortOrder,
+    ]
   );
 
   const {
@@ -105,8 +148,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
         cursor: pageParam,
         limit: 50,
         searchTerm: resolvedSearchTerm,
-        tagFilter: resolvedTagFilter,
-        keyGroupFilter: resolvedKeyGroupFilter,
+        tagFilters: resolvedTagFilters,
+        keyGroupFilters: resolvedKeyGroupFilters,
+        statusFilter: resolvedStatusFilter,
+        sortBy,
+        sortOrder,
       });
       if (!result.ok) {
         throw new Error(result.error);
@@ -114,7 +160,29 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
       return result.data;
     },
     getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
-    initialPageParam: undefined as { id: number; createdAt: string } | undefined,
+    initialPageParam: 0,
+    placeholderData: (previousData) => previousData,
+  });
+
+  // Independent tag query - breaks circular dependency
+  const { data: allTags = [] } = useQuery({
+    queryKey: ["userTags"],
+    queryFn: async () => {
+      const result = await getAllUserTags();
+      if (!result.ok) throw new Error(result.error);
+      return result.data;
+    },
+    enabled: isAdmin,
+  });
+
+  const { data: allKeyGroups = [] } = useQuery({
+    queryKey: ["userKeyGroups"],
+    queryFn: async () => {
+      const result = await getAllUserKeyGroups();
+      if (!result.ok) throw new Error(result.error);
+      return result.data;
+    },
+    enabled: isAdmin,
   });
 
   const allUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]);
@@ -126,6 +194,28 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
   const isInitialLoading = isLoading && allUsers.length === 0;
   const isRefreshing = isFetching && !isInitialLoading && !isFetchingNextPage;
 
+  useEffect(() => {
+    setPendingTagFilters(tagFilters);
+  }, [tagFilters]);
+
+  useEffect(() => {
+    setPendingKeyGroupFilters(keyGroupFilters);
+  }, [keyGroupFilters]);
+
+  useEffect(() => {
+    const appliedKey = tagFilters.slice().sort().join("|");
+    if (debouncedPendingTagsKey !== appliedKey) {
+      setTagFilters(pendingTagFilters);
+    }
+  }, [debouncedPendingTagsKey, pendingTagFilters, tagFilters]);
+
+  useEffect(() => {
+    const appliedKey = keyGroupFilters.slice().sort().join("|");
+    if (debouncedPendingKeyGroupsKey !== appliedKey) {
+      setKeyGroupFilters(pendingKeyGroupFilters);
+    }
+  }, [debouncedPendingKeyGroupsKey, pendingKeyGroupFilters, keyGroupFilters]);
+
   // Batch edit / multi-select state
   const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
   const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(() => new Set());
@@ -178,19 +268,34 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     setShowCreateDialog(open);
   }, []);
 
-  // Extract unique tags from users
-  const uniqueTags = useMemo(() => {
-    const tags = visibleUsers.flatMap((u) => u.tags || []);
-    return [...new Set(tags)].sort();
-  }, [visibleUsers]);
-
-  // Extract unique key groups from users (split comma-separated tags)
-  const uniqueKeyGroups = useMemo(() => {
-    const groups = visibleUsers.flatMap(
-      (u) => u.keys?.flatMap((k) => splitTags(k.providerGroup)) || []
+  const hasPendingFilterChanges = useMemo(() => {
+    const normalize = (values: string[]) => [...values].sort().join("|");
+    return (
+      normalize(pendingTagFilters) !== normalize(tagFilters) ||
+      normalize(pendingKeyGroupFilters) !== normalize(keyGroupFilters)
     );
-    return [...new Set(groups)].sort();
-  }, [visibleUsers]);
+  }, [pendingTagFilters, tagFilters, pendingKeyGroupFilters, keyGroupFilters]);
+
+  const handleApplyFilters = useCallback(() => {
+    if (!hasPendingFilterChanges) return;
+    setTagFilters(pendingTagFilters);
+    setKeyGroupFilters(pendingKeyGroupFilters);
+  }, [pendingTagFilters, pendingKeyGroupFilters, hasPendingFilterChanges]);
+
+  const handleTagCommit = useCallback((nextTags: string[]) => {
+    setTagFilters(nextTags);
+    setPendingTagFilters(nextTags);
+  }, []);
+
+  const handleKeyGroupCommit = useCallback((nextGroups: string[]) => {
+    setKeyGroupFilters(nextGroups);
+    setPendingKeyGroupFilters(nextGroups);
+  }, []);
+
+  // Use independent query instead of circular dependency
+  const uniqueTags = isAdmin ? allTags : [];
+
+  const uniqueKeyGroups = isAdmin ? allKeyGroups : [];
 
   const matchingKeyIds = useMemo(() => {
     const matchingIds = new Set<number>();
@@ -209,7 +314,8 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             (key.providerGroup || "").toLowerCase().includes(normalizedTerm));
 
         const matchesKeyGroup =
-          keyGroupFilter !== "all" && splitTags(key.providerGroup).includes(keyGroupFilter);
+          keyGroupFilters.length > 0 &&
+          keyGroupFilters.some((filter) => splitTags(key.providerGroup).includes(filter));
 
         if (matchesSearch || matchesKeyGroup) {
           matchingIds.add(key.id);
@@ -218,10 +324,10 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     }
 
     return matchingIds;
-  }, [visibleUsers, searchTerm, keyGroupFilter]);
+  }, [visibleUsers, searchTerm, keyGroupFilters]);
 
   // Determine if we should highlight keys (either search or keyGroup filter is active)
-  const shouldHighlightKeys = searchTerm.trim().length > 0 || keyGroupFilter !== "all";
+  const shouldHighlightKeys = searchTerm.trim().length > 0 || keyGroupFilters.length > 0;
   const selfUser = useMemo(() => (isAdmin ? undefined : visibleUsers[0]), [isAdmin, visibleUsers]);
 
   const allVisibleUserIds = useMemo(() => visibleUsers.map((user) => user.id), [visibleUsers]);
@@ -376,8 +482,15 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
 
   const scrollResetKey = useMemo(
     () =>
-      `${resolvedSearchTerm ?? ""}|${resolvedTagFilter ?? "all"}|${resolvedKeyGroupFilter ?? "all"}`,
-    [resolvedSearchTerm, resolvedTagFilter, resolvedKeyGroupFilter]
+      `${resolvedSearchTerm ?? ""}|${resolvedTagFilters?.join(",") ?? "all"}|${resolvedKeyGroupFilters?.join(",") ?? "all"}|${resolvedStatusFilter ?? "all"}|${sortBy}|${sortOrder}`,
+    [
+      resolvedSearchTerm,
+      resolvedTagFilters,
+      resolvedKeyGroupFilters,
+      resolvedStatusFilter,
+      sortBy,
+      sortOrder,
+    ]
   );
 
   return (
@@ -405,9 +518,9 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
       </div>
 
       {/* Toolbar with search and filters */}
-      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
+      <div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center">
         {/* Search input */}
-        <div className="relative flex-1 max-w-sm">
+        <div className="relative flex-1 min-w-[220px] sm:min-w-[280px]">
           <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
             placeholder={t("toolbar.searchPlaceholder")}
@@ -419,51 +532,102 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
 
         {isAdmin ? (
           <>
-            {/* Tag filter */}
+            {/* Tag filter - Multi-select */}
             {isInitialLoading ? (
-              <Skeleton className="h-9 w-[180px]" />
+              <Skeleton className="h-9 w-[240px]" />
             ) : (
               uniqueTags.length > 0 && (
-                <Select value={tagFilter} onValueChange={setTagFilter}>
-                  <SelectTrigger className="w-[180px]">
-                    <SelectValue placeholder={t("toolbar.tagFilter")} />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="all">{t("toolbar.allTags")}</SelectItem>
-                    {uniqueTags.map((tag) => (
-                      <SelectItem key={tag} value={tag}>
-                        <Badge variant="secondary" className="mr-1 text-xs">
-                          {tag}
-                        </Badge>
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
+                <div className="w-[200px] sm:w-[220px]">
+                  <TagInput
+                    value={pendingTagFilters}
+                    onChange={setPendingTagFilters}
+                    onChangeCommit={handleTagCommit}
+                    suggestions={uniqueTags}
+                    placeholder={t("toolbar.tagFilter")}
+                    maxVisibleTags={2}
+                    allowDuplicates={false}
+                    validateTag={(tag) => uniqueTags.includes(tag)}
+                    onSuggestionsClose={handleApplyFilters}
+                    clearable
+                    clearLabel={tCommon("clear")}
+                    className="h-9 flex-nowrap items-center overflow-hidden py-1"
+                  />
+                </div>
               )
             )}
 
             {/* Key group filter */}
             {isInitialLoading ? (
-              <Skeleton className="h-9 w-[180px]" />
+              <Skeleton className="h-9 w-[240px]" />
             ) : (
               uniqueKeyGroups.length > 0 && (
-                <Select value={keyGroupFilter} onValueChange={setKeyGroupFilter}>
-                  <SelectTrigger className="w-[180px]">
-                    <SelectValue placeholder={t("toolbar.keyGroupFilter")} />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="all">{t("toolbar.allKeyGroups")}</SelectItem>
-                    {uniqueKeyGroups.map((group) => (
-                      <SelectItem key={group} value={group}>
-                        <Badge variant="outline" className="mr-1 text-xs">
-                          {group}
-                        </Badge>
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
+                <div className="w-[200px] sm:w-[220px]">
+                  <TagInput
+                    value={pendingKeyGroupFilters}
+                    onChange={setPendingKeyGroupFilters}
+                    onChangeCommit={handleKeyGroupCommit}
+                    suggestions={uniqueKeyGroups}
+                    placeholder={t("toolbar.keyGroupFilter")}
+                    maxVisibleTags={2}
+                    allowDuplicates={false}
+                    validateTag={(tag) => uniqueKeyGroups.includes(tag)}
+                    onSuggestionsClose={handleApplyFilters}
+                    clearable
+                    clearLabel={tCommon("clear")}
+                    className="h-9 flex-nowrap items-center overflow-hidden py-1"
+                  />
+                </div>
               )
             )}
+
+            {/* Sort by field */}
+            <Select value={sortBy} onValueChange={(value) => setSortBy(value as typeof sortBy)}>
+              <SelectTrigger className="w-[160px]">
+                <SelectValue placeholder={t("toolbar.sortBy")} />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="createdAt">{t("toolbar.sortByCreatedAt")}</SelectItem>
+                <SelectItem value="name">{t("toolbar.sortByName")}</SelectItem>
+                <SelectItem value="tags">{t("toolbar.sortByTags")}</SelectItem>
+                <SelectItem value="expiresAt">{t("toolbar.sortByExpiresAt")}</SelectItem>
+                <SelectItem value="limit5hUsd">{t("toolbar.sortByLimit5h")}</SelectItem>
+                <SelectItem value="limitDailyUsd">{t("toolbar.sortByLimitDaily")}</SelectItem>
+                <SelectItem value="limitWeeklyUsd">{t("toolbar.sortByLimitWeekly")}</SelectItem>
+                <SelectItem value="limitMonthlyUsd">{t("toolbar.sortByLimitMonthly")}</SelectItem>
+              </SelectContent>
+            </Select>
+
+            {/* Sort order */}
+            <Select
+              value={sortOrder}
+              onValueChange={(value) => setSortOrder(value as "asc" | "desc")}
+            >
+              <SelectTrigger className="w-[110px]">
+                <SelectValue placeholder={t("toolbar.sortOrder")} />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="asc">{t("toolbar.ascending")}</SelectItem>
+                <SelectItem value="desc">{t("toolbar.descending")}</SelectItem>
+              </SelectContent>
+            </Select>
+
+            {/* Status filter */}
+            <Select
+              value={statusFilter}
+              onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}
+            >
+              <SelectTrigger className="w-[160px]">
+                <SelectValue placeholder={t("toolbar.statusFilter")} />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="all">{t("toolbar.allStatus")}</SelectItem>
+                <SelectItem value="active">{t("toolbar.statusActive")}</SelectItem>
+                <SelectItem value="expired">{t("toolbar.statusExpired")}</SelectItem>
+                <SelectItem value="expiringSoon">{t("toolbar.statusExpiringSoon")}</SelectItem>
+                <SelectItem value="enabled">{t("toolbar.statusEnabled")}</SelectItem>
+                <SelectItem value="disabled">{t("toolbar.statusDisabled")}</SelectItem>
+              </SelectContent>
+            </Select>
           </>
         ) : null}
       </div>
@@ -476,7 +640,9 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
         </div>
       ) : (
         <div className="space-y-3">
-          {isRefreshing ? <InlineLoading label={tCommon("loading")} /> : null}
+          <div className="h-4">
+            {isRefreshing ? <InlineLoading label={tCommon("loading")} /> : null}
+          </div>
           <UserManagementTable
             users={visibleUsers}
             hasNextPage={hasNextPage}

+ 95 - 7
src/components/ui/tag-input.tsx

@@ -2,6 +2,7 @@
 
 import { X } from "lucide-react";
 import * as React from "react";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { cn } from "@/lib/utils";
 import { Badge } from "./badge";
 
@@ -19,8 +20,14 @@ export type TagInputSuggestion =
 export interface TagInputProps extends Omit<React.ComponentProps<"input">, "value" | "onChange"> {
   value: string[];
   onChange: (tags: string[]) => void;
+  onChangeCommit?: (tags: string[]) => void;
   maxTags?: number;
   maxTagLength?: number;
+  maxVisibleTags?: number;
+  onSuggestionsClose?: () => void;
+  clearable?: boolean;
+  clearLabel?: string;
+  onClear?: () => void;
   allowDuplicates?: boolean;
   separator?: RegExp;
   placeholder?: string;
@@ -38,8 +45,14 @@ const DEFAULT_TAG_PATTERN = /^[a-zA-Z0-9_-]+$/; // 字母、数字、下划线
 export function TagInput({
   value = [],
   onChange,
+  onChangeCommit,
   maxTags,
   maxTagLength = 50,
+  maxVisibleTags,
+  onSuggestionsClose,
+  clearable = false,
+  clearLabel,
+  onClear,
   allowDuplicates = false,
   separator = DEFAULT_SEPARATOR,
   placeholder,
@@ -56,6 +69,32 @@ export function TagInput({
   const inputRef = React.useRef<HTMLInputElement>(null);
   const containerRef = React.useRef<HTMLDivElement>(null);
 
+  const normalizedMaxVisible = React.useMemo(() => {
+    if (maxVisibleTags === undefined) return undefined;
+    return Math.max(0, maxVisibleTags);
+  }, [maxVisibleTags]);
+
+  const { visibleTags, hiddenTags } = React.useMemo(() => {
+    if (normalizedMaxVisible === undefined) {
+      return { visibleTags: value, hiddenTags: [] as string[] };
+    }
+    return {
+      visibleTags: value.slice(0, normalizedMaxVisible),
+      hiddenTags: value.slice(normalizedMaxVisible),
+    };
+  }, [value, normalizedMaxVisible]);
+
+  const previousShowSuggestions = React.useRef(showSuggestions);
+
+  React.useEffect(() => {
+    if (previousShowSuggestions.current && !showSuggestions) {
+      onSuggestionsClose?.();
+    }
+    previousShowSuggestions.current = showSuggestions;
+  }, [showSuggestions, onSuggestionsClose]);
+
+  const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]";
+
   // Normalize suggestions so callers can provide either strings or { value, label } objects.
   const normalizedSuggestions = React.useMemo(() => {
     return suggestions.map((s) => (typeof s === "string" ? { value: s, label: s } : s));
@@ -113,6 +152,12 @@ export function TagInput({
     [validateTag, defaultValidateTag]
   );
 
+  const commitIfClosed = React.useCallback(() => {
+    if (!showSuggestions) {
+      onSuggestionsClose?.();
+    }
+  }, [showSuggestions, onSuggestionsClose]);
+
   const addTag = React.useCallback(
     (tag: string, keepOpen = false) => {
       const trimmedTag = tag.trim();
@@ -123,9 +168,10 @@ export function TagInput({
           setShowSuggestions(false);
         }
         setHighlightedIndex(-1);
+        commitIfClosed();
       }
     },
-    [value, onChange, handleValidateTag]
+    [value, onChange, handleValidateTag, commitIfClosed]
   );
 
   const addTagsBatch = React.useCallback(
@@ -149,15 +195,20 @@ export function TagInput({
       setInputValue("");
       setShowSuggestions(false);
       setHighlightedIndex(-1);
+      commitIfClosed();
     },
-    [value, onChange, handleValidateTag]
+    [value, onChange, handleValidateTag, commitIfClosed]
   );
 
   const removeTag = React.useCallback(
     (indexToRemove: number) => {
-      onChange(value.filter((_, index) => index !== indexToRemove));
+      const nextTags = value.filter((_, index) => index !== indexToRemove);
+      onChange(nextTags);
+      onChangeCommit?.(nextTags);
+      setShowSuggestions(false);
+      setHighlightedIndex(-1);
     },
-    [value, onChange]
+    [value, onChange, onChangeCommit]
   );
 
   const handleKeyDown = React.useCallback(
@@ -262,19 +313,33 @@ export function TagInput({
     [addTag]
   );
 
+  const handleClear = React.useCallback(() => {
+    const nextTags: string[] = [];
+    if (onClear) {
+      onClear();
+    } else {
+      onChange(nextTags);
+    }
+    onChangeCommit?.(nextTags);
+    setInputValue("");
+    setShowSuggestions(false);
+    setHighlightedIndex(-1);
+  }, [onClear, onChange, onChangeCommit]);
+
   return (
-    <div ref={containerRef} className="relative">
+    <div ref={containerRef} className="relative group">
       <div
         className={cn(
           "flex min-h-9 w-full flex-wrap gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none",
           "focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
           "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
           disabled && "pointer-events-none cursor-not-allowed opacity-50",
+          clearable && value.length > 0 && "pr-8",
           className
         )}
         onClick={() => inputRef.current?.focus()}
       >
-        {value.map((tag, index) => (
+        {visibleTags.map((tag, index) => (
           <Badge
             key={`${tag}-${index}`}
             variant="secondary"
@@ -296,6 +361,18 @@ export function TagInput({
             )}
           </Badge>
         ))}
+        {hiddenTags.length > 0 && (
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <Badge variant="secondary" className="pr-2 pl-2 py-1 h-auto">
+                <span className="text-xs">+{hiddenTags.length}</span>
+              </Badge>
+            </TooltipTrigger>
+            <TooltipContent side="bottom" sideOffset={6}>
+              {hiddenTags.join(", ")}
+            </TooltipContent>
+          </Tooltip>
+        )}
         <input
           ref={inputRef}
           type="text"
@@ -308,12 +385,23 @@ export function TagInput({
           disabled={disabled}
           placeholder={value.length === 0 ? placeholder : undefined}
           className={cn(
-            "flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm"
+            "flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm",
+            inputMinWidthClass
           )}
           autoComplete="off"
           {...props}
         />
       </div>
+      {clearable && value.length > 0 && (
+        <button
+          type="button"
+          onClick={handleClear}
+          className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 group-focus-within:opacity-100"
+          aria-label={clearLabel || "Clear"}
+        >
+          <X className="h-3.5 w-3.5" />
+        </button>
+      )}
       {/* 建议下拉列表 */}
       {showSuggestions && filteredSuggestions.length > 0 && (
         <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-48 overflow-auto">

+ 147 - 37
src/repository/user.ts

@@ -1,33 +1,41 @@
 "use server";
 
-import { and, asc, eq, isNull, sql } from "drizzle-orm";
+import { and, asc, eq, isNull, type SQL, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { keys as keysTable, users } from "@/drizzle/schema";
 import type { CreateUserData, UpdateUserData, User } from "@/types/user";
 import { toUser } from "./_shared/transformers";
 
-export interface UserListCursor {
-  id: number;
-  /** ISO string */
-  createdAt: string;
-}
-
 export interface UserListBatchFilters {
-  /** Keyset pagination cursor */
-  cursor?: UserListCursor;
+  /** Offset pagination cursor */
+  cursor?: number;
   /** Page size */
   limit?: number;
   /** Search in username / note */
   searchTerm?: string;
-  /** Filter by a single tag */
-  tagFilter?: string;
+  /** Filter by multiple tags (OR logic: users with ANY selected tag) */
+  tagFilters?: string[];
   /** Filter by provider group (derived from keys) */
-  keyGroupFilter?: string;
+  keyGroupFilters?: string[];
+  /** Filter by user status */
+  statusFilter?: "all" | "active" | "expired" | "expiringSoon" | "enabled" | "disabled";
+  /** Sort field */
+  sortBy?:
+    | "name"
+    | "tags"
+    | "expiresAt"
+    | "limit5hUsd"
+    | "limitDailyUsd"
+    | "limitWeeklyUsd"
+    | "limitMonthlyUsd"
+    | "createdAt";
+  /** Sort direction */
+  sortOrder?: "asc" | "desc";
 }
 
 export interface UserListBatchResult {
   users: User[];
-  nextCursor: UserListCursor | null;
+  nextCursor: number | null;
   hasMore: boolean;
 }
 
@@ -137,14 +145,21 @@ export async function searchUsersForFilter(
 }
 
 /**
- * Cursor-based pagination (keyset pagination) for user list.
- *
- * Cursor uses composite key (created_at, id) to ensure stable ordering.
+ * Offset-based pagination for user list.
  */
 export async function findUserListBatch(
   filters: UserListBatchFilters
 ): Promise<UserListBatchResult> {
-  const { cursor, limit = 50, searchTerm, tagFilter, keyGroupFilter } = filters;
+  const {
+    cursor,
+    limit = 50,
+    searchTerm,
+    tagFilters,
+    keyGroupFilters,
+    statusFilter,
+    sortBy = "createdAt",
+    sortOrder = "asc",
+  } = filters;
 
   const conditions = [isNull(users.deletedAt)];
 
@@ -174,28 +189,83 @@ export async function findUserListBatch(
     )`);
   }
 
-  const trimmedTag = tagFilter?.trim();
-  if (trimmedTag) {
-    conditions.push(sql`${users.tags} @> ${JSON.stringify([trimmedTag])}::jsonb`);
+  // Multi-tag filter with OR logic: users with ANY selected tag
+  const normalizedTags = (tagFilters ?? []).map((tag) => tag.trim()).filter(Boolean);
+  let tagFilterCondition: SQL | undefined;
+  if (normalizedTags.length > 0) {
+    const tagConditions = normalizedTags.map(
+      (tag) => sql`${users.tags} @> ${JSON.stringify([tag])}::jsonb`
+    );
+    tagFilterCondition = sql`(${sql.join(tagConditions, sql` OR `)})`;
   }
 
-  const trimmedGroup = keyGroupFilter?.trim();
-  if (trimmedGroup) {
-    conditions.push(
-      sql`${trimmedGroup} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))`
+  const trimmedGroups = (keyGroupFilters ?? []).map((group) => group.trim()).filter(Boolean);
+  let keyGroupFilterCondition: SQL | undefined;
+  if (trimmedGroups.length > 0) {
+    const groupConditions = trimmedGroups.map(
+      (group) =>
+        sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))`
     );
+    keyGroupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`;
   }
 
-  // Cursor-based pagination: WHERE (created_at, id) > (cursor_created_at, cursor_id)
-  if (cursor) {
-    conditions.push(
-      sql`(${users.createdAt}, ${users.id}) > (${cursor.createdAt}::timestamptz, ${cursor.id})`
-    );
+  if (tagFilterCondition && keyGroupFilterCondition) {
+    conditions.push(sql`(${tagFilterCondition} OR ${keyGroupFilterCondition})`);
+  } else if (tagFilterCondition) {
+    conditions.push(tagFilterCondition);
+  } else if (keyGroupFilterCondition) {
+    conditions.push(keyGroupFilterCondition);
   }
 
+  // Status filter
+  if (statusFilter && statusFilter !== "all") {
+    switch (statusFilter) {
+      case "active":
+        // User is enabled and either never expires or expires in the future
+        conditions.push(
+          sql`(${users.expiresAt} IS NULL OR ${users.expiresAt} >= NOW()) AND ${users.isEnabled} = true`
+        );
+        break;
+      case "expired":
+        // User has expired (expiresAt is in the past)
+        conditions.push(sql`${users.expiresAt} < NOW()`);
+        break;
+      case "expiringSoon":
+        // User expires within 7 days
+        conditions.push(
+          sql`${users.expiresAt} IS NOT NULL AND ${users.expiresAt} >= NOW() AND ${users.expiresAt} <= NOW() + INTERVAL '7 days'`
+        );
+        break;
+      case "enabled":
+        // User is enabled regardless of expiration
+        conditions.push(sql`${users.isEnabled} = true`);
+        break;
+      case "disabled":
+        // User is disabled
+        conditions.push(sql`${users.isEnabled} = false`);
+        break;
+    }
+  }
+
+  const offset = Math.max(cursor ?? 0, 0);
+
   // Fetch limit + 1 to determine if there are more records
   const fetchLimit = limit + 1;
 
+  // Build dynamic ORDER BY based on sortBy and sortOrder
+  const sortColumn = {
+    name: users.name,
+    tags: users.tags,
+    expiresAt: users.expiresAt,
+    limit5hUsd: users.limit5hUsd,
+    limitDailyUsd: users.dailyLimitUsd,
+    limitWeeklyUsd: users.limitWeeklyUsd,
+    limitMonthlyUsd: users.limitMonthlyUsd,
+    createdAt: users.createdAt,
+  }[sortBy];
+
+  const orderByClause = sortOrder === "asc" ? asc(sortColumn) : sql`${sortColumn} DESC`;
+
   const results = await db
     .select({
       id: users.id,
@@ -207,7 +277,6 @@ export async function findUserListBatch(
       providerGroup: users.providerGroup,
       tags: users.tags,
       createdAt: users.createdAt,
-      createdAtRaw: sql<string>`to_char(${users.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
       limit5hUsd: users.limit5hUsd,
@@ -224,17 +293,14 @@ export async function findUserListBatch(
     })
     .from(users)
     .where(and(...conditions))
-    .orderBy(asc(users.createdAt), asc(users.id))
-    .limit(fetchLimit);
+    .orderBy(orderByClause, asc(users.id))
+    .limit(fetchLimit)
+    .offset(offset);
 
   const hasMore = results.length > limit;
   const usersToReturn = hasMore ? results.slice(0, limit) : results;
 
-  const lastUser = usersToReturn[usersToReturn.length - 1];
-  const nextCursor =
-    hasMore && lastUser?.createdAtRaw
-      ? { createdAt: lastUser.createdAtRaw, id: lastUser.id }
-      : null;
+  const nextCursor = hasMore ? offset + limit : null;
 
   return {
     users: usersToReturn.map(toUser),
@@ -390,3 +456,47 @@ export async function markUserExpired(userId: number): Promise<boolean> {
 
   return result.length > 0;
 }
+
+/**
+ * Get all unique tags from all users (for tag filter dropdown)
+ * Returns tags from all users regardless of current filters
+ */
+export async function getAllUserTags(): Promise<string[]> {
+  const result = await db.select({ tags: users.tags }).from(users).where(isNull(users.deletedAt));
+
+  const allTags = new Set<string>();
+  for (const row of result) {
+    if (row.tags && Array.isArray(row.tags)) {
+      for (const tag of row.tags) {
+        allTags.add(tag);
+      }
+    }
+  }
+
+  return Array.from(allTags).sort();
+}
+
+/**
+ * Get all unique provider groups from users (for key group filter dropdown)
+ * Returns groups from all users regardless of current filters
+ */
+export async function getAllUserProviderGroups(): Promise<string[]> {
+  const result = await db
+    .select({ providerGroup: users.providerGroup })
+    .from(users)
+    .where(isNull(users.deletedAt));
+
+  const allGroups = new Set<string>();
+  for (const row of result) {
+    const groups = row.providerGroup
+      ?.split(",")
+      .map((group) => group.trim())
+      .filter(Boolean);
+    if (!groups || groups.length === 0) continue;
+    for (const group of groups) {
+      allGroups.add(group);
+    }
+  }
+
+  return Array.from(allGroups).sort();
+}