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

Merge pull request #439 from ding113/fix/issue-431-438-key-deletion-bugs

fix: 修复删除密钥的两个问题 (#431 #438)
Ding 1 месяц назад
Родитель
Сommit
1c7ee2fd59

+ 3 - 1
messages/en/dashboard.json

@@ -1164,6 +1164,7 @@
       "keySaveFailed": "Failed to save key",
       "keyDeleteFailed": "Failed to delete key",
       "saveSuccess": "Changes saved successfully",
+      "atLeastOneKeyEnabled": "At least one key must be enabled",
       "operationFailed": "Operation failed",
       "userDisabled": "User has been disabled",
       "userEnabled": "User has been enabled",
@@ -1488,7 +1489,8 @@
         },
         "enableStatus": {
           "label": "Enable Status",
-          "description": "Disabled keys cannot be used"
+          "description": "Disabled keys cannot be used",
+          "cannotDisableTooltip": "Cannot disable the last enabled key"
         },
         "balanceQueryPage": {
           "label": "Independent Personal Usage Page",

+ 3 - 1
messages/ja/dashboard.json

@@ -1126,6 +1126,7 @@
       "keySaveFailed": "キーの保存に失敗しました",
       "keyDeleteFailed": "キーの削除に失敗しました",
       "saveSuccess": "変更が保存されました",
+      "atLeastOneKeyEnabled": "少なくとも1つのキーを有効にする必要があります",
       "operationFailed": "操作に失敗しました",
       "userDisabled": "ユーザーが無効化されました",
       "userEnabled": "ユーザーが有効化されました",
@@ -1448,7 +1449,8 @@
         },
         "enableStatus": {
           "label": "有効状態",
-          "description": "無効化されたキーは使用できません"
+          "description": "無効化されたキーは使用できません",
+          "cannotDisableTooltip": "最後の有効なキーを無効にできません"
         },
         "balanceQueryPage": {
           "label": "独立した個人使用量ページ",

+ 3 - 1
messages/ru/dashboard.json

@@ -1137,6 +1137,7 @@
       "keySaveFailed": "Не удалось сохранить ключ",
       "keyDeleteFailed": "Не удалось удалить ключ",
       "saveSuccess": "Изменения сохранены",
+      "atLeastOneKeyEnabled": "Необходимо оставить хотя бы один активный ключ",
       "operationFailed": "Операция не удалась",
       "userDisabled": "Пользователь отключен",
       "userEnabled": "Пользователь активирован",
@@ -1461,7 +1462,8 @@
         },
         "enableStatus": {
           "label": "Статус включения",
-          "description": "Отключённые ключи не могут использоваться"
+          "description": "Отключённые ключи не могут использоваться",
+          "cannotDisableTooltip": "Невозможно отключить последний активный ключ"
         },
         "balanceQueryPage": {
           "label": "Независимая страница использования",

+ 3 - 1
messages/zh-CN/dashboard.json

@@ -1165,6 +1165,7 @@
       "keySaveFailed": "保存密钥失败",
       "keyDeleteFailed": "删除密钥失败",
       "saveSuccess": "保存成功",
+      "atLeastOneKeyEnabled": "至少需要保留一个启用的密钥",
       "operationFailed": "操作失败",
       "userDisabled": "用户已禁用",
       "userEnabled": "用户已启用",
@@ -1487,7 +1488,8 @@
         },
         "enableStatus": {
           "label": "启用状态",
-          "description": "禁用后此密钥将无法使用。禁用后仅管理员可启用。"
+          "description": "禁用后此密钥将无法使用。禁用后仅管理员可启用。",
+          "cannotDisableTooltip": "无法禁用最后一个启用的密钥"
         },
         "balanceQueryPage": {
           "label": "独立个人用量页面",

+ 3 - 1
messages/zh-TW/dashboard.json

@@ -1138,6 +1138,7 @@
       "keySaveFailed": "儲存金鑰失敗",
       "keyDeleteFailed": "刪除金鑰失敗",
       "saveSuccess": "儲存成功",
+      "atLeastOneKeyEnabled": "至少需要保留一個啟用的金鑰",
       "operationFailed": "操作失敗",
       "userDisabled": "使用者已停用",
       "userEnabled": "使用者已啟用",
@@ -1460,7 +1461,8 @@
         },
         "enableStatus": {
           "label": "啟用狀態",
-          "description": "停用後此金鑰將無法使用。停用後僅管理員可啟用。"
+          "description": "停用後此金鑰將無法使用。停用後僅管理員可啟用。",
+          "cannotDisableTooltip": "無法停用最後一個啟用的金鑰"
         },
         "balanceQueryPage": {
           "label": "獨立個人用量頁面",

+ 14 - 9
src/actions/keys.ts

@@ -85,6 +85,7 @@ export async function addKey(data: {
   userId: number;
   name: string;
   expiresAt?: string;
+  isEnabled?: boolean;
   canLoginWebUi?: boolean;
   limit5hUsd?: number | null;
   limitDailyUsd?: number | null;
@@ -245,7 +246,7 @@ export async function addKey(data: {
       user_id: data.userId,
       name: validatedData.name,
       key: generatedKey,
-      is_enabled: true,
+      is_enabled: data.isEnabled ?? true,
       expires_at: expiresAt,
       can_login_web_ui: validatedData.canLoginWebUi,
       limit_5h_usd: validatedData.limit5hUsd,
@@ -475,12 +476,16 @@ export async function removeKey(keyId: number): Promise<ActionResult> {
       return { ok: false, error: "无权限执行此操作" };
     }
 
-    const activeKeyCount = await countActiveKeysByUser(key.userId);
-    if (activeKeyCount <= 1) {
-      return {
-        ok: false,
-        error: "该用户至少需要保留一个可用的密钥,无法删除最后一个密钥",
-      };
+    // 只有删除启用的密钥时,才需要检查是否是最后一个启用的密钥
+    // 删除禁用的密钥不会影响用户的可用密钥数量
+    if (key.isEnabled) {
+      const activeKeyCount = await countActiveKeysByUser(key.userId);
+      if (activeKeyCount <= 1) {
+        return {
+          ok: false,
+          error: "该用户至少需要保留一个可用的密钥,无法删除最后一个密钥",
+        };
+      }
     }
 
     // 非 admin 删除时的额外检查:确保删除后用户仍有分组(防止分组被清空从而绕过限制)
@@ -837,7 +842,7 @@ export async function batchUpdateKeys(
             const currentEnabledCount = userEnabledCounts.get(userId) ?? 0;
             if (currentEnabledCount - disableCount < 1) {
               throw new BatchUpdateError(
-                tError("CANNOT_DISABLE_LAST_KEY") || "无法禁用最后一个可用密钥",
+                tError("CANNOT_DISABLE_LAST_KEY"),
                 ERROR_CODES.OPERATION_FAILED
               );
             }
@@ -894,7 +899,7 @@ export async function batchUpdateKeys(
 
           if (Number(remainingEnabled?.count ?? 0) < 1) {
             throw new BatchUpdateError(
-              tError("CANNOT_DISABLE_LAST_KEY") || "无法禁用最后一个可用密钥",
+              tError("CANNOT_DISABLE_LAST_KEY"),
               ERROR_CODES.OPERATION_FAILED
             );
           }

+ 29 - 6
src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx

@@ -16,6 +16,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { cn } from "@/lib/utils";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
@@ -44,6 +45,8 @@ export interface KeyEditSectionProps {
   };
   /** Admin 可自由编辑 providerGroup */
   isAdmin?: boolean;
+  /** 是否是最后一个启用的 key (用于禁用 Switch 防止全部禁用) */
+  isLastEnabledKey?: boolean;
   userProviderGroup?: string;
   onChange: {
     (field: string, value: any): void;
@@ -74,7 +77,11 @@ export interface KeyEditSectionProps {
         noGroupHint?: string;
       };
       cacheTtl: { label: string; options: Record<string, string> };
-      enableStatus?: { label: string; description: string };
+      enableStatus?: {
+        label: string;
+        description: string;
+        cannotDisableTooltip?: string;
+      };
     };
     limitRules: any;
     quickExpire: any;
@@ -127,6 +134,7 @@ const TTL_ORDER = ["inherit", "5m", "1h"] as const;
 export function KeyEditSection({
   keyData,
   isAdmin = false,
+  isLastEnabledKey = false,
   userProviderGroup,
   onChange,
   scrollRef,
@@ -339,11 +347,26 @@ export function KeyEditSection({
               {translations.fields.enableStatus?.description || "Disabled keys cannot be used"}
             </p>
           </div>
-          <Switch
-            id={`key-enable-${keyData.id}`}
-            checked={keyData.isEnabled ?? true}
-            onCheckedChange={(checked) => onChange("isEnabled", checked)}
-          />
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <div className="flex items-center">
+                <Switch
+                  id={`key-enable-${keyData.id}`}
+                  checked={keyData.isEnabled ?? true}
+                  disabled={isLastEnabledKey}
+                  onCheckedChange={(checked) => onChange("isEnabled", checked)}
+                />
+              </div>
+            </TooltipTrigger>
+            {isLastEnabledKey && (
+              <TooltipContent>
+                <p className="text-xs">
+                  {translations.fields.enableStatus?.cannotDisableTooltip ||
+                    "Cannot disable the last enabled key"}
+                </p>
+              </TooltipContent>
+            )}
+          </Tooltip>
         </div>
       </section>
 

+ 2 - 0
src/app/[locale]/dashboard/_components/user/forms/quick-renew-key-dialog.tsx

@@ -101,6 +101,8 @@ export function QuickRenewKeyDialog({
           }
         }
         const newDate = addDays(baseDate, days);
+        // Set to end of day to ensure full day validity
+        newDate.setHours(23, 59, 59, 999);
         const shouldEnable =
           !keyData.status || keyData.status === "disabled" ? enableOnRenew : undefined;
         const result = await onConfirm(keyData.id, newDate, shouldEnable);

+ 14 - 0
src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx

@@ -299,6 +299,15 @@ function UnifiedEditDialogInner({
     onSubmit: async (data) => {
       startTransition(async () => {
         try {
+          // 验证: 编辑模式下,至少需要一个启用的 key(防止用户禁用所有 key)
+          if (mode === "edit") {
+            const enabledKeyCount = data.keys.filter((k) => k.isEnabled).length;
+            if (enabledKeyCount === 0) {
+              toast.error(t("editDialog.atLeastOneKeyEnabled"));
+              return;
+            }
+          }
+
           if (mode === "create") {
             if (isKeyOnlyMode) {
               const targetUserId = user?.id ?? currentUser?.id;
@@ -429,6 +438,7 @@ function UnifiedEditDialogInner({
                   userId: user.id,
                   name: key.name,
                   expiresAt: key.expiresAt || undefined,
+                  isEnabled: key.isEnabled,
                   canLoginWebUi: key.canLoginWebUi,
                   providerGroup: normalizeProviderGroup(key.providerGroup),
                   cacheTtlPreference: key.cacheTtlPreference,
@@ -916,6 +926,9 @@ function UnifiedEditDialogInner({
                 const isExpanded =
                   mode === "create" || keys.length === 1 || expandedKeyIds.has(key.id);
                 const showCollapseButton = mode === "edit" && keys.length > 1;
+                // 计算当前是否是最后一个启用的 key
+                const enabledKeysCount = keys.filter((k) => k.isEnabled).length;
+                const isLastEnabledKey = key.isEnabled && enabledKeysCount === 1;
 
                 return (
                   <div
@@ -1003,6 +1016,7 @@ function UnifiedEditDialogInner({
                             limitConcurrentSessions: key.limitConcurrentSessions ?? 0,
                           }}
                           isAdmin={isAdmin}
+                          isLastEnabledKey={isLastEnabledKey}
                           userProviderGroup={user?.providerGroup ?? undefined}
                           onChange={
                             ((fieldOrBatch: string | Record<string, any>, value?: any) =>

+ 4 - 0
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { useQueryClient } from "@tanstack/react-query";
 import { ChevronDown, ChevronRight, SquarePen } from "lucide-react";
 import { useLocale, useTranslations } from "next-intl";
 import { useEffect, useState, useTransition } from "react";
@@ -127,6 +128,7 @@ export function UserKeyTableRow({
   const tBatchEdit = useTranslations("dashboard.userManagement.batchEdit");
   const tUserStatus = useTranslations("dashboard.userManagement.userStatus");
   const router = useRouter();
+  const queryClient = useQueryClient();
   const [_isPending, startTransition] = useTransition();
   const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
   // 乐观更新:本地状态跟踪启用状态
@@ -177,6 +179,8 @@ export function UserKeyTableRow({
         return;
       }
       toast.success(tUserStatus("deleteSuccess"));
+      // 使 React Query 缓存失效,确保数据刷新
+      queryClient.invalidateQueries({ queryKey: ["users"] });
       router.refresh();
     });
   };

+ 4 - 0
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { useQueryClient } from "@tanstack/react-query";
 import { useVirtualizer } from "@tanstack/react-virtual";
 import { Loader2, Users } from "lucide-react";
 import { useRouter } from "next/navigation";
@@ -118,6 +119,7 @@ export function UserManagementTable({
   translations,
 }: UserManagementTableProps) {
   const router = useRouter();
+  const queryClient = useQueryClient();
   const tUserList = useTranslations("dashboard.userList");
   const tUserMgmt = useTranslations("dashboard.userManagement");
   const isAdmin = currentUser?.role === "admin";
@@ -350,6 +352,8 @@ export function UserManagementTable({
       }
       toast.success(tUserMgmt("quickRenew.success"));
       // 刷新服务端数据(成功后乐观更新状态会在useEffect中被props覆盖)
+      // 使 React Query 缓存失效,确保数据刷新
+      queryClient.invalidateQueries({ queryKey: ["users"] });
       router.refresh();
       return { ok: true };
     } catch (error) {