فهرست منبع

feat(dashboard): improve user management, statistics reset, and i18n (#610)

* fix(dashboard/logs): add reset options to filters and use short time format

- Add "All keys" SelectItem to API Key filter dropdown
- Add "All status codes" SelectItem to Status Code filter dropdown
- Use __all__ value instead of empty string (Radix Select requirement)
- Add formatDateDistanceShort() for compact time display (2h ago, 3d ago)
- Update RelativeTime component to use short format

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(providers): add Auto Sort button to dashboard and fix i18n

- Add AutoSortPriorityDialog to dashboard/providers page
- EN: "Auto Sort Priority" -> "Auto Sort"
- RU: "Авто сортировка приоритета" -> "Автосорт"
- RU: "Добавить провайдера" -> "Добавить поставщика"

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(i18n): improve Russian localization and fix login errors

Russian localization improvements:
- Menu: "Управление поставщиками" -> "Поставщики"
- Menu: "Доступность" -> "Мониторинг"
- Filters: "Последние 7/30 дней" -> "7д/30д"
- Dashboard: "Статистика использования" -> "Статистика"
- Dashboard: "Показать статистику..." -> "Только ваши ключи"
- Quota: add missing translations (manageNotice, withQuotas, etc.)

Login error localization:
- Fix issue where login errors displayed in Chinese ("无效或已过期") regardless of locale
- Add locale detection from NEXT_LOCALE cookie and Accept-Language header
- Add 3 new error keys: apiKeyRequired, apiKeyInvalidOrExpired, serverError
- Support all 5 languages: EN, JA, RU, ZH-CN, ZH-TW
- Remove product name from login privacyNote for all locales

Files changed:
- messages/*/auth.json: new error keys, update privacyNote
- messages/ru/dashboard.json, messages/ru/quota.json: Russian improvements
- src/app/api/auth/login/route.ts: add getLocaleFromRequest()

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

* feat(dashboard/users): improve user management with key quotas and tokens

- Add access/model restrictions support (allowedClients/allowedModels)
- Add tokens column and refresh button to users table
- Add todayTokens calculation in repository layer (sum all token types)
- Add visual status indicators with color-coded icons (active/disabled/expiring/expired)
- Allow users to view their own key quota (was admin-only)
- Fix React Query cache invalidation on status toggle
- Fix filter logic: change tag/keyGroup from OR to AND
- Refactor time display: move formatDateDistanceShort to component with i18n
- Add fixed header/footer to key dialogs for better UX

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

* feat(dashboard/users): add reset statistics with optimized Redis pipeline

- Implement reset all statistics functionality for admins
- Optimize Redis operations: replace sequential redis.keys() with parallel SCAN
- Add scanPattern() helper for production-safe key scanning
- Comprehensive error handling and performance metrics logging
- 50-100x performance improvement with no Redis blocking

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

* fix(lint): apply biome formatting and fix React hooks dependencies

- Fix useEffect dependencies in RelativeTime component (wrap formatShortDistance in useCallback)
- Remove unused effectiveGroupText variable in key-row-item.tsx
- Apply consistent LF line endings across modified files

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: address code review feedback from PR #610

- Remove duplicate max-h class in edit-key-dialog.tsx (keep max-h-[90dvh] only)
- Add try-catch fallback for getTranslations in login route catch block

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: translate Russian comments to English for consistency

Addresses Gemini Code Assist review feedback.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* test(users): add unit tests for resetUserAllStatistics function

Cover all requirement scenarios:
- Permission check (admin-only)
- User not found handling
- Success path with DB + Redis cleanup
- Redis not ready graceful handling
- Redis partial failure warning
- scanPattern failure warning
- pipeline.exec failure error logging
- Unexpected error handling
- Empty keys list handling

10 test cases with full mock coverage.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
miraserver 3 هفته پیش
والد
کامیت
472cfd475b
51فایلهای تغییر یافته به همراه1334 افزوده شده و 179 حذف شده
  1. 5 2
      messages/en/auth.json
  2. 11 1
      messages/en/common.json
  3. 75 5
      messages/en/dashboard.json
  4. 8 4
      messages/en/quota.json
  5. 1 1
      messages/en/settings/providers/autoSort.json
  6. 5 2
      messages/ja/auth.json
  7. 11 1
      messages/ja/common.json
  8. 45 7
      messages/ja/dashboard.json
  9. 14 4
      messages/ja/quota.json
  10. 6 3
      messages/ru/auth.json
  11. 11 1
      messages/ru/common.json
  12. 105 18
      messages/ru/dashboard.json
  13. 40 5
      messages/ru/quota.json
  14. 1 1
      messages/ru/settings/providers/autoSort.json
  15. 2 2
      messages/ru/settings/providers/form/title.json
  16. 2 2
      messages/ru/settings/providers/strings.json
  17. 5 2
      messages/zh-CN/auth.json
  18. 11 1
      messages/zh-CN/common.json
  19. 26 3
      messages/zh-CN/dashboard.json
  20. 5 2
      messages/zh-TW/auth.json
  21. 11 1
      messages/zh-TW/common.json
  22. 45 7
      messages/zh-TW/dashboard.json
  23. 14 4
      messages/zh-TW/quota.json
  24. 6 4
      src/actions/key-quota.ts
  25. 130 6
      src/actions/users.ts
  26. 8 8
      src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
  27. 2 0
      src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx
  28. 1 1
      src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
  29. 100 4
      src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
  30. 5 7
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  31. 4 0
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  32. 1 1
      src/app/[locale]/dashboard/_components/user/key-list.tsx
  33. 35 20
      src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  34. 74 24
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  35. 18 1
      src/app/[locale]/dashboard/_components/user/user-management-table.tsx
  36. 11 4
      src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx
  37. 1 1
      src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx
  38. 1 1
      src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
  39. 2 0
      src/app/[locale]/dashboard/providers/page.tsx
  40. 57 2
      src/app/[locale]/dashboard/users/users-page-client.tsx
  41. 37 3
      src/app/api/auth/login/route.ts
  42. 3 3
      src/components/form/form-layout.tsx
  43. 5 3
      src/components/section.tsx
  44. 40 4
      src/components/ui/relative-time.tsx
  45. 1 0
      src/lib/redis/index.ts
  46. 32 0
      src/lib/redis/scan-helper.ts
  47. 12 2
      src/repository/key.ts
  48. 1 1
      src/repository/user.ts
  49. 3 0
      src/types/user.ts
  50. 246 0
      tests/unit/actions/users-reset-all-statistics.test.ts
  51. 39 0
      tests/unit/lib/redis/scan-helper.test.ts

+ 5 - 2
messages/en/auth.json

@@ -30,7 +30,7 @@
     "solutionTitle": "Solutions:",
     "solutionTitle": "Solutions:",
     "useHttps": "Use HTTPS to access the system (recommended)",
     "useHttps": "Use HTTPS to access the system (recommended)",
     "disableSecureCookies": "Set ENABLE_SECURE_COOKIES=false in .env (reduces security)",
     "disableSecureCookies": "Set ENABLE_SECURE_COOKIES=false in .env (reduces security)",
-    "privacyNote": "Please use your API Key to log in to the Claude Code Hub admin panel"
+    "privacyNote": "Please use your API Key to log in to the admin panel"
   },
   },
   "errors": {
   "errors": {
     "loginFailed": "Login failed",
     "loginFailed": "Login failed",
@@ -38,6 +38,9 @@
     "invalidToken": "Invalid authentication token",
     "invalidToken": "Invalid authentication token",
     "tokenRequired": "Authentication token is required",
     "tokenRequired": "Authentication token is required",
     "sessionExpired": "Your session has expired, please log in again",
     "sessionExpired": "Your session has expired, please log in again",
-    "unauthorized": "Unauthorized, please log in first"
+    "unauthorized": "Unauthorized, please log in first",
+    "apiKeyRequired": "Please enter API Key",
+    "apiKeyInvalidOrExpired": "API Key is invalid or expired",
+    "serverError": "Login failed, please try again later"
   }
   }
 }
 }

+ 11 - 1
messages/en/common.json

@@ -48,5 +48,15 @@
   "theme": "Theme",
   "theme": "Theme",
   "light": "Light",
   "light": "Light",
   "dark": "Dark",
   "dark": "Dark",
-  "system": "System"
+  "system": "System",
+  "relativeTimeShort": {
+    "now": "now",
+    "secondsAgo": "{count}s ago",
+    "minutesAgo": "{count}m ago",
+    "hoursAgo": "{count}h ago",
+    "daysAgo": "{count}d ago",
+    "weeksAgo": "{count}w ago",
+    "monthsAgo": "{count}mo ago",
+    "yearsAgo": "{count}y ago"
+  }
 }
 }

+ 75 - 5
messages/en/dashboard.json

@@ -785,6 +785,15 @@
       "defaultDescription": "default includes providers without groupTag.",
       "defaultDescription": "default includes providers without groupTag.",
       "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)."
       "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)."
     },
     },
+    "cacheTtl": {
+      "label": "Cache TTL Override",
+      "description": "Force Anthropic prompt cache TTL for requests containing cache_control.",
+      "options": {
+        "inherit": "No override (follow provider/client)",
+        "5m": "5m",
+        "1h": "1h"
+      }
+    },
     "successTitle": "Key Created Successfully",
     "successTitle": "Key Created Successfully",
     "successDescription": "Your API key has been created successfully.",
     "successDescription": "Your API key has been created successfully.",
     "generatedKey": {
     "generatedKey": {
@@ -1148,18 +1157,21 @@
         "name": "Key name",
         "name": "Key name",
         "key": "Key",
         "key": "Key",
         "group": "Group",
         "group": "Group",
-        "todayUsage": "Today's usage",
+        "todayUsage": "Requests today",
         "todayCost": "Today's cost",
         "todayCost": "Today's cost",
+        "todayTokens": "Tokens today",
         "lastUsed": "Last used",
         "lastUsed": "Last used",
         "actions": "Actions",
         "actions": "Actions",
         "quotaButton": "View Quota Usage",
         "quotaButton": "View Quota Usage",
         "fields": {
         "fields": {
-          "callsLabel": "Calls",
+          "callsLabel": "Requests",
+          "tokensLabel": "Tokens",
           "costLabel": "Cost"
           "costLabel": "Cost"
         }
         }
       },
       },
       "expand": "Expand",
       "expand": "Expand",
       "collapse": "Collapse",
       "collapse": "Collapse",
+      "refresh": "Refresh",
       "noKeys": "No keys",
       "noKeys": "No keys",
       "defaultGroup": "default",
       "defaultGroup": "default",
       "userStatus": {
       "userStatus": {
@@ -1250,7 +1262,18 @@
       "userEnabled": "User has been enabled",
       "userEnabled": "User has been enabled",
       "deleteFailed": "Failed to delete user",
       "deleteFailed": "Failed to delete user",
       "userDeleted": "User has been deleted",
       "userDeleted": "User has been deleted",
-      "saving": "Saving..."
+      "saving": "Saving...",
+      "resetData": {
+        "title": "Reset Statistics",
+        "description": "Delete all request logs and usage data for this user. This action is irreversible.",
+        "error": "Failed to reset data",
+        "button": "Reset Statistics",
+        "confirmTitle": "Reset All Statistics?",
+        "confirmDescription": "This will permanently delete all request logs and usage statistics for this user. This action cannot be undone.",
+        "confirm": "Yes, Reset All",
+        "loading": "Resetting...",
+        "success": "All statistics have been reset"
+      }
     },
     },
     "batchEdit": {
     "batchEdit": {
       "enterMode": "Batch Edit",
       "enterMode": "Batch Edit",
@@ -1351,6 +1374,41 @@
     },
     },
     "limitRules": {
     "limitRules": {
       "addRule": "Add limit rule",
       "addRule": "Add limit rule",
+      "title": "Add Limit Rule",
+      "description": "Select limit type and set value",
+      "cancel": "Cancel",
+      "confirm": "Save",
+      "fields": {
+        "type": {
+          "label": "Limit Type",
+          "placeholder": "Select"
+        },
+        "value": {
+          "label": "Value",
+          "placeholder": "Enter"
+        }
+      },
+      "daily": {
+        "mode": {
+          "label": "Daily Reset Mode",
+          "fixed": "Fixed time reset",
+          "rolling": "Rolling window (24h)",
+          "helperRolling": "Rolling 24-hour window from first request"
+        },
+        "time": {
+          "label": "Reset Time",
+          "placeholder": "HH:mm"
+        }
+      },
+      "limitTypes": {
+        "limitRpm": "RPM Limit",
+        "limit5h": "5-Hour Limit",
+        "limitDaily": "Daily Limit",
+        "limitWeekly": "Weekly Limit",
+        "limitMonthly": "Monthly Limit",
+        "limitTotal": "Total Limit",
+        "limitSessions": "Concurrent Sessions"
+      },
       "ruleTypes": {
       "ruleTypes": {
         "limitRpm": "RPM limit",
         "limitRpm": "RPM limit",
         "limit5h": "5-hour limit",
         "limit5h": "5-hour limit",
@@ -1360,6 +1418,12 @@
         "limitTotal": "Total limit",
         "limitTotal": "Total limit",
         "limitSessions": "Concurrent sessions"
         "limitSessions": "Concurrent sessions"
       },
       },
+      "errors": {
+        "missingType": "Please select a limit type",
+        "invalidValue": "Please enter a valid value",
+        "invalidTime": "Please enter a valid time (HH:mm)"
+      },
+      "overwriteHint": "This type already exists, saving will overwrite the existing value",
       "dailyMode": {
       "dailyMode": {
         "fixed": "Fixed reset time",
         "fixed": "Fixed reset time",
         "rolling": "Rolling window (24h)"
         "rolling": "Rolling window (24h)"
@@ -1372,8 +1436,7 @@
         "500": "$500"
         "500": "$500"
       },
       },
       "alreadySet": "Configured",
       "alreadySet": "Configured",
-      "confirmAdd": "Add",
-      "cancel": "Cancel"
+      "confirmAdd": "Add"
     },
     },
     "quickExpire": {
     "quickExpire": {
       "oneWeek": "In 1 week",
       "oneWeek": "In 1 week",
@@ -1596,6 +1659,13 @@
           }
           }
         },
         },
         "overwriteHint": "This type already exists, saving will overwrite the existing value"
         "overwriteHint": "This type already exists, saving will overwrite the existing value"
+      },
+      "accessRestrictions": {
+        "title": "Access Restrictions",
+        "models": "Allowed Models",
+        "clients": "Allowed Clients",
+        "noRestrictions": "No restrictions",
+        "inheritedFromUser": "Inherited from user settings"
       }
       }
     }
     }
   },
   },

+ 8 - 4
messages/en/quota.json

@@ -288,7 +288,8 @@
       "limit5hUsd": {
       "limit5hUsd": {
         "label": "5-Hour Cost Limit (USD)",
         "label": "5-Hour Cost Limit (USD)",
         "placeholder": "Leave blank for unlimited",
         "placeholder": "Leave blank for unlimited",
-        "description": "Maximum cost within 5 hours"
+        "description": "Maximum cost within 5 hours",
+        "descriptionWithUserLimit": "Cannot exceed user 5-hour limit ({limit})"
       },
       },
       "limitDailyUsd": {
       "limitDailyUsd": {
         "label": "Daily Cost Limit (USD)",
         "label": "Daily Cost Limit (USD)",
@@ -314,12 +315,14 @@
       "limitWeeklyUsd": {
       "limitWeeklyUsd": {
         "label": "Weekly Cost Limit (USD)",
         "label": "Weekly Cost Limit (USD)",
         "placeholder": "Leave blank for unlimited",
         "placeholder": "Leave blank for unlimited",
-        "description": "Maximum cost per week"
+        "description": "Maximum cost per week",
+        "descriptionWithUserLimit": "Cannot exceed user weekly limit ({limit})"
       },
       },
       "limitMonthlyUsd": {
       "limitMonthlyUsd": {
         "label": "Monthly Cost Limit (USD)",
         "label": "Monthly Cost Limit (USD)",
         "placeholder": "Leave blank for unlimited",
         "placeholder": "Leave blank for unlimited",
-        "description": "Maximum cost per month"
+        "description": "Maximum cost per month",
+        "descriptionWithUserLimit": "Cannot exceed user monthly limit ({limit})"
       },
       },
       "limitTotalUsd": {
       "limitTotalUsd": {
         "label": "Total Cost Limit (USD)",
         "label": "Total Cost Limit (USD)",
@@ -330,7 +333,8 @@
       "limitConcurrentSessions": {
       "limitConcurrentSessions": {
         "label": "Concurrent Session Limit",
         "label": "Concurrent Session Limit",
         "placeholder": "0 means unlimited",
         "placeholder": "0 means unlimited",
-        "description": "Number of simultaneous conversations"
+        "description": "Number of simultaneous conversations",
+        "descriptionWithUserLimit": "Cannot exceed user session limit ({limit})"
       },
       },
       "providerGroup": {
       "providerGroup": {
         "label": "Provider Group",
         "label": "Provider Group",

+ 1 - 1
messages/en/settings/providers/autoSort.json

@@ -1,5 +1,5 @@
 {
 {
-  "button": "Auto Sort Priority",
+  "button": "Auto Sort",
   "changeCount": "{count} providers will be updated",
   "changeCount": "{count} providers will be updated",
   "changesTitle": "Change Details",
   "changesTitle": "Change Details",
   "confirm": "Apply Changes",
   "confirm": "Apply Changes",

+ 5 - 2
messages/ja/auth.json

@@ -30,7 +30,7 @@
     "solutionTitle": "解決策:",
     "solutionTitle": "解決策:",
     "useHttps": "HTTPS を使用してアクセスしてください (推奨)",
     "useHttps": "HTTPS を使用してアクセスしてください (推奨)",
     "disableSecureCookies": ".env ファイルで ENABLE_SECURE_COOKIES=false を設定 (セキュリティが低下します)",
     "disableSecureCookies": ".env ファイルで ENABLE_SECURE_COOKIES=false を設定 (セキュリティが低下します)",
-    "privacyNote": "API Keyを使用してClaude Code Hub管理画面にログインしてください"
+    "privacyNote": "API Keyを使用して管理画面にログインしてください"
   },
   },
   "errors": {
   "errors": {
     "loginFailed": "ログインに失敗しました",
     "loginFailed": "ログインに失敗しました",
@@ -38,6 +38,9 @@
     "invalidToken": "無効な認証トークン",
     "invalidToken": "無効な認証トークン",
     "tokenRequired": "認証トークンが必要です",
     "tokenRequired": "認証トークンが必要です",
     "sessionExpired": "セッションの有効期限が切れています。もう一度ログインしてください",
     "sessionExpired": "セッションの有効期限が切れています。もう一度ログインしてください",
-    "unauthorized": "認可されていません。先にログインしてください"
+    "unauthorized": "認可されていません。先にログインしてください",
+    "apiKeyRequired": "API Keyを入力してください",
+    "apiKeyInvalidOrExpired": "API Keyが無効または期限切れです",
+    "serverError": "ログインに失敗しました。しばらく後に再度お試しください"
   }
   }
 }
 }

+ 11 - 1
messages/ja/common.json

@@ -48,5 +48,15 @@
   "theme": "テーマ",
   "theme": "テーマ",
   "light": "ライト",
   "light": "ライト",
   "dark": "ダーク",
   "dark": "ダーク",
-  "system": "システム設定"
+  "system": "システム設定",
+  "relativeTimeShort": {
+    "now": "たった今",
+    "secondsAgo": "{count}秒前",
+    "minutesAgo": "{count}分前",
+    "hoursAgo": "{count}時間前",
+    "daysAgo": "{count}日前",
+    "weeksAgo": "{count}週間前",
+    "monthsAgo": "{count}ヶ月前",
+    "yearsAgo": "{count}年前"
+  }
 }
 }

+ 45 - 7
messages/ja/dashboard.json

@@ -721,7 +721,8 @@
     "limit5hUsd": {
     "limit5hUsd": {
       "label": "5時間消費上限 (USD)",
       "label": "5時間消費上限 (USD)",
       "placeholder": "空白の場合は無制限",
       "placeholder": "空白の場合は無制限",
-      "description": "5時間以内の最大消費金額"
+      "description": "5時間以内の最大消費金額",
+      "descriptionWithUserLimit": "5時間以内の最大消費金額 (ユーザー上限: {limit})"
     },
     },
     "limitDailyUsd": {
     "limitDailyUsd": {
       "label": "1日の消費上限 (USD)",
       "label": "1日の消費上限 (USD)",
@@ -747,17 +748,26 @@
     "limitWeeklyUsd": {
     "limitWeeklyUsd": {
       "label": "週間消費上限 (USD)",
       "label": "週間消費上限 (USD)",
       "placeholder": "空白の場合は無制限",
       "placeholder": "空白の場合は無制限",
-      "description": "1週間あたりの最大消費金額"
+      "description": "1週間あたりの最大消費金額",
+      "descriptionWithUserLimit": "1週間あたりの最大消費金額 (ユーザー上限: {limit})"
     },
     },
     "limitMonthlyUsd": {
     "limitMonthlyUsd": {
       "label": "月間消費上限 (USD)",
       "label": "月間消費上限 (USD)",
       "placeholder": "空白の場合は無制限",
       "placeholder": "空白の場合は無制限",
-      "description": "1ヶ月あたりの最大消費金額"
+      "description": "1ヶ月あたりの最大消費金額",
+      "descriptionWithUserLimit": "1ヶ月あたりの最大消費金額 (ユーザー上限: {limit})"
+    },
+    "limitTotalUsd": {
+      "label": "総消費上限 (USD)",
+      "placeholder": "空白の場合は無制限",
+      "description": "累計消費上限(リセットなし)",
+      "descriptionWithUserLimit": "ユーザーの総上限を超えることはできません ({limit})"
     },
     },
     "limitConcurrentSessions": {
     "limitConcurrentSessions": {
       "label": "同時セッション上限",
       "label": "同時セッション上限",
       "placeholder": "0は無制限を意味します",
       "placeholder": "0は無制限を意味します",
-      "description": "同時に実行される会話の数"
+      "description": "同時に実行される会話の数",
+      "descriptionWithUserLimit": "最大セッション数 (ユーザー上限: {limit})"
     },
     },
     "providerGroup": {
     "providerGroup": {
       "label": "プロバイダーグループ",
       "label": "プロバイダーグループ",
@@ -766,6 +776,15 @@
       "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます",
       "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます",
       "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)"
       "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)"
     },
     },
+    "cacheTtl": {
+      "label": "Cache TTL上書き",
+      "description": "cache_controlを含むリクエストに対してAnthropic prompt cache TTLを強制します。",
+      "options": {
+        "inherit": "上書きしない(プロバイダー/クライアントに従う)",
+        "5m": "5m",
+        "1h": "1h"
+      }
+    },
     "successTitle": "キーが正常に作成されました",
     "successTitle": "キーが正常に作成されました",
     "successDescription": "APIキーが正常に作成されました。",
     "successDescription": "APIキーが正常に作成されました。",
     "generatedKey": {
     "generatedKey": {
@@ -1119,18 +1138,21 @@
         "name": "キー名",
         "name": "キー名",
         "key": "キー",
         "key": "キー",
         "group": "グループ",
         "group": "グループ",
-        "todayUsage": "本日の使用量",
+        "todayUsage": "本日のリクエスト",
         "todayCost": "本日の消費",
         "todayCost": "本日の消費",
+        "todayTokens": "本日のトークン",
         "lastUsed": "最終使用",
         "lastUsed": "最終使用",
         "actions": "アクション",
         "actions": "アクション",
         "quotaButton": "クォータ使用状況を表示",
         "quotaButton": "クォータ使用状況を表示",
         "fields": {
         "fields": {
-          "callsLabel": "呼び出し",
+          "callsLabel": "リクエスト",
+          "tokensLabel": "トークン",
           "costLabel": "消費"
           "costLabel": "消費"
         }
         }
       },
       },
       "expand": "展開",
       "expand": "展開",
       "collapse": "折りたたむ",
       "collapse": "折りたたむ",
+      "refresh": "更新",
       "noKeys": "キーなし",
       "noKeys": "キーなし",
       "defaultGroup": "default",
       "defaultGroup": "default",
       "userStatus": {
       "userStatus": {
@@ -1182,6 +1204,10 @@
       "currentExpiry": "現在の有効期限",
       "currentExpiry": "現在の有効期限",
       "neverExpires": "無期限",
       "neverExpires": "無期限",
       "expired": "期限切れ",
       "expired": "期限切れ",
+      "quickExtensionLabel": "クイック延長",
+      "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)",
+      "customDateLabel": "有効期限を設定",
+      "customDateHint": "有効期限を直接指定",
       "quickOptions": {
       "quickOptions": {
         "7days": "7 日",
         "7days": "7 日",
         "30days": "30 日",
         "30days": "30 日",
@@ -1190,6 +1216,7 @@
       },
       },
       "customDate": "カスタム日付",
       "customDate": "カスタム日付",
       "enableOnRenew": "同時にユーザーを有効化",
       "enableOnRenew": "同時にユーザーを有効化",
+      "enableKeyOnRenew": "同時にキーを有効化",
       "cancel": "キャンセル",
       "cancel": "キャンセル",
       "confirm": "更新を確認",
       "confirm": "更新を確認",
       "confirming": "更新中...",
       "confirming": "更新中...",
@@ -1212,7 +1239,18 @@
       "userEnabled": "ユーザーが有効化されました",
       "userEnabled": "ユーザーが有効化されました",
       "deleteFailed": "ユーザーの削除に失敗しました",
       "deleteFailed": "ユーザーの削除に失敗しました",
       "userDeleted": "ユーザーが削除されました",
       "userDeleted": "ユーザーが削除されました",
-      "saving": "保存しています..."
+      "saving": "保存しています...",
+      "resetData": {
+        "title": "統計リセット",
+        "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。",
+        "error": "データのリセットに失敗しました",
+        "button": "統計をリセット",
+        "confirmTitle": "すべての統計をリセットしますか?",
+        "confirmDescription": "このユーザーのすべてのリクエストログと使用統計を完全に削除します。この操作は取り消せません。",
+        "confirm": "はい、すべてリセット",
+        "loading": "リセット中...",
+        "success": "すべての統計がリセットされました"
+      }
     },
     },
     "batchEdit": {
     "batchEdit": {
       "enterMode": "一括編集",
       "enterMode": "一括編集",

+ 14 - 4
messages/ja/quota.json

@@ -265,7 +265,8 @@
       "limit5hUsd": {
       "limit5hUsd": {
         "label": "5時間消費上限 (USD)",
         "label": "5時間消費上限 (USD)",
         "placeholder": "空欄の場合は無制限",
         "placeholder": "空欄の場合は無制限",
-        "description": "5時間以内の最大消費金額"
+        "description": "5時間以内の最大消費金額",
+        "descriptionWithUserLimit": "ユーザーの5時間制限を超えることはできません ({limit})"
       },
       },
       "limitDailyUsd": {
       "limitDailyUsd": {
         "label": "日次消費上限 (USD)",
         "label": "日次消費上限 (USD)",
@@ -291,17 +292,26 @@
       "limitWeeklyUsd": {
       "limitWeeklyUsd": {
         "label": "週間消費上限 (USD)",
         "label": "週間消費上限 (USD)",
         "placeholder": "空欄の場合は無制限",
         "placeholder": "空欄の場合は無制限",
-        "description": "毎週の最大消費金額"
+        "description": "毎週の最大消費金額",
+        "descriptionWithUserLimit": "ユーザーの週間制限を超えることはできません ({limit})"
       },
       },
       "limitMonthlyUsd": {
       "limitMonthlyUsd": {
         "label": "月間消費上限 (USD)",
         "label": "月間消費上限 (USD)",
         "placeholder": "空欄の場合は無制限",
         "placeholder": "空欄の場合は無制限",
-        "description": "毎月の最大消費金額"
+        "description": "毎月の最大消費金額",
+        "descriptionWithUserLimit": "ユーザーの月間制限を超えることはできません ({limit})"
+      },
+      "limitTotalUsd": {
+        "label": "総消費上限 (USD)",
+        "placeholder": "空欄の場合は無制限",
+        "description": "累計消費上限(リセットなし)",
+        "descriptionWithUserLimit": "ユーザーの総制限を超えることはできません ({limit})"
       },
       },
       "limitConcurrentSessions": {
       "limitConcurrentSessions": {
         "label": "同時セッション上限",
         "label": "同時セッション上限",
         "placeholder": "0 = 無制限",
         "placeholder": "0 = 無制限",
-        "description": "同時実行可能な会話数"
+        "description": "同時実行可能な会話数",
+        "descriptionWithUserLimit": "ユーザーのセッション制限を超えることはできません ({limit})"
       },
       },
       "providerGroup": {
       "providerGroup": {
         "label": "プロバイダーグループ",
         "label": "プロバイダーグループ",

+ 6 - 3
messages/ru/auth.json

@@ -1,7 +1,7 @@
 {
 {
   "form": {
   "form": {
     "title": "Панель входа",
     "title": "Панель входа",
-    "description": "Получите доступ к унифицированной консоли администратора с помощью вашего API ключа"
+    "description": "Введите ваш API ключ для доступа к данным"
   },
   },
   "login": {
   "login": {
     "title": "Вход",
     "title": "Вход",
@@ -30,7 +30,7 @@
     "solutionTitle": "Решения:",
     "solutionTitle": "Решения:",
     "useHttps": "Используйте HTTPS для доступа к системе (рекомендуется)",
     "useHttps": "Используйте HTTPS для доступа к системе (рекомендуется)",
     "disableSecureCookies": "Установите ENABLE_SECURE_COOKIES=false в .env (снижает безопасность)",
     "disableSecureCookies": "Установите ENABLE_SECURE_COOKIES=false в .env (снижает безопасность)",
-    "privacyNote": "Пожалуйста, используйте свой API Key для входа в панель администрирования Claude Code Hub"
+    "privacyNote": "Если вы забыли свой API ключ, обратитесь к администратору"
   },
   },
   "errors": {
   "errors": {
     "loginFailed": "Ошибка входа",
     "loginFailed": "Ошибка входа",
@@ -38,6 +38,9 @@
     "invalidToken": "Неверный токен аутентификации",
     "invalidToken": "Неверный токен аутентификации",
     "tokenRequired": "Требуется токен аутентификации",
     "tokenRequired": "Требуется токен аутентификации",
     "sessionExpired": "Ваша сессия истекла, пожалуйста, войдите снова",
     "sessionExpired": "Ваша сессия истекла, пожалуйста, войдите снова",
-    "unauthorized": "Не авторизовано, пожалуйста, сначала войдите"
+    "unauthorized": "Не авторизовано, пожалуйста, сначала войдите",
+    "apiKeyRequired": "Пожалуйста, введите API ключ",
+    "apiKeyInvalidOrExpired": "API ключ недействителен или истёк",
+    "serverError": "Ошибка входа, попробуйте позже"
   }
   }
 }
 }

+ 11 - 1
messages/ru/common.json

@@ -48,5 +48,15 @@
   "theme": "Тема",
   "theme": "Тема",
   "light": "Светлая",
   "light": "Светлая",
   "dark": "Тёмная",
   "dark": "Тёмная",
-  "system": "Системная"
+  "system": "Системная",
+  "relativeTimeShort": {
+    "now": "сейчас",
+    "secondsAgo": "{count}с назад",
+    "minutesAgo": "{count}м назад",
+    "hoursAgo": "{count}ч назад",
+    "daysAgo": "{count}д назад",
+    "weeksAgo": "{count}н назад",
+    "monthsAgo": "{count}мес назад",
+    "yearsAgo": "{count}г назад"
+  }
 }
 }

+ 105 - 18
messages/ru/dashboard.json

@@ -536,11 +536,11 @@
     "dashboard": "Панель",
     "dashboard": "Панель",
     "usageLogs": "Журналы",
     "usageLogs": "Журналы",
     "leaderboard": "Лидеры",
     "leaderboard": "Лидеры",
-    "availability": "Доступность",
+    "availability": "Мониторинг",
     "myQuota": "Моя квота",
     "myQuota": "Моя квота",
     "quotasManagement": "Квоты",
     "quotasManagement": "Квоты",
     "userManagement": "Пользователи",
     "userManagement": "Пользователи",
-    "providers": "Управление поставщиками",
+    "providers": "Поставщики",
     "documentation": "Доки",
     "documentation": "Доки",
     "systemSettings": "Настройки",
     "systemSettings": "Настройки",
     "feedback": "Обратная связь",
     "feedback": "Обратная связь",
@@ -548,7 +548,7 @@
     "logout": "Выход"
     "logout": "Выход"
   },
   },
   "statistics": {
   "statistics": {
-    "title": "Статистика использования",
+    "title": "Статистика",
     "cost": "Сумма расходов",
     "cost": "Сумма расходов",
     "calls": "Количество вызовов API",
     "calls": "Количество вызовов API",
     "totalCost": "Общая сумма расходов",
     "totalCost": "Общая сумма расходов",
@@ -556,18 +556,18 @@
     "timeRange": {
     "timeRange": {
       "today": "Сегодня",
       "today": "Сегодня",
       "todayDescription": "Использование за сегодня",
       "todayDescription": "Использование за сегодня",
-      "7days": "Последние 7 дней",
+      "7days": "7д",
       "7daysDescription": "Использование за последние 7 дней",
       "7daysDescription": "Использование за последние 7 дней",
-      "30days": "Последние 30 дней",
+      "30days": "30д",
       "30daysDescription": "Использование за последние 30 дней",
       "30daysDescription": "Использование за последние 30 дней",
       "thisMonth": "Этот месяц",
       "thisMonth": "Этот месяц",
       "thisMonthDescription": "Использование за этот месяц",
       "thisMonthDescription": "Использование за этот месяц",
       "default": "Использование"
       "default": "Использование"
     },
     },
     "mode": {
     "mode": {
-      "keys": "Показать статистику использования только для ваших ключей",
+      "keys": "Только ваши ключи",
       "mixed": "Показать детали ваших ключей и сводку других пользователей",
       "mixed": "Показать детали ваших ключей и сводку других пользователей",
-      "users": "Показать статистику использования всех пользователей"
+      "users": "Показать для всех"
     },
     },
     "legend": {
     "legend": {
       "selectAll": "Выбрать все",
       "selectAll": "Выбрать все",
@@ -723,7 +723,8 @@
     "limit5hUsd": {
     "limit5hUsd": {
       "label": "Лимит расходов за 5 часов (USD)",
       "label": "Лимит расходов за 5 часов (USD)",
       "placeholder": "Оставьте пустым для неограниченного",
       "placeholder": "Оставьте пустым для неограниченного",
-      "description": "Максимальный расход в течение 5 часов"
+      "description": "Максимальный расход в течение 5 часов",
+      "descriptionWithUserLimit": "Максимальный расход за 5 часов (Лимит пользователя: {limit})"
     },
     },
     "limitDailyUsd": {
     "limitDailyUsd": {
       "label": "Дневной лимит расходов (USD)",
       "label": "Дневной лимит расходов (USD)",
@@ -749,17 +750,26 @@
     "limitWeeklyUsd": {
     "limitWeeklyUsd": {
       "label": "Недельный лимит расходов (USD)",
       "label": "Недельный лимит расходов (USD)",
       "placeholder": "Оставьте пустым для неограниченного",
       "placeholder": "Оставьте пустым для неограниченного",
-      "description": "Максимальный расход в неделю"
+      "description": "Максимальный расход в неделю",
+      "descriptionWithUserLimit": "Максимальный расход в неделю (Лимит пользователя: {limit})"
     },
     },
     "limitMonthlyUsd": {
     "limitMonthlyUsd": {
       "label": "Месячный лимит расходов (USD)",
       "label": "Месячный лимит расходов (USD)",
       "placeholder": "Оставьте пустым для неограниченного",
       "placeholder": "Оставьте пустым для неограниченного",
-      "description": "Максимальный расход в месяц"
+      "description": "Максимальный расход в месяц",
+      "descriptionWithUserLimit": "Максимальный расход в месяц (Лимит пользователя: {limit})"
+    },
+    "limitTotalUsd": {
+      "label": "Общий лимит расходов (USD)",
+      "placeholder": "Оставьте пустым для неограниченного",
+      "description": "Максимальная сумма расходов (без сброса)",
+      "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})"
     },
     },
     "limitConcurrentSessions": {
     "limitConcurrentSessions": {
       "label": "Лимит параллельных сеансов",
       "label": "Лимит параллельных сеансов",
       "placeholder": "0 означает неограниченно",
       "placeholder": "0 означает неограниченно",
-      "description": "Количество одновременных разговоров"
+      "description": "Количество одновременных разговоров",
+      "descriptionWithUserLimit": "Максимум сеансов (Лимит пользователя: {limit})"
     },
     },
     "providerGroup": {
     "providerGroup": {
       "label": "Группа провайдеров",
       "label": "Группа провайдеров",
@@ -768,6 +778,15 @@
       "defaultDescription": "default включает провайдеров без groupTag.",
       "defaultDescription": "default включает провайдеров без groupTag.",
       "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)."
       "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)."
     },
     },
+    "cacheTtl": {
+      "label": "Переопределение Cache TTL",
+      "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.",
+      "options": {
+        "inherit": "Не переопределять (следовать провайдеру/клиенту)",
+        "5m": "5m",
+        "1h": "1h"
+      }
+    },
     "successTitle": "Ключ успешно создан",
     "successTitle": "Ключ успешно создан",
     "successDescription": "Ваш API-ключ был успешно создан.",
     "successDescription": "Ваш API-ключ был успешно создан.",
     "generatedKey": {
     "generatedKey": {
@@ -922,7 +941,7 @@
       "last1h": "Последний час",
       "last1h": "Последний час",
       "last6h": "Последние 6 часов",
       "last6h": "Последние 6 часов",
       "last24h": "Последние 24 часа",
       "last24h": "Последние 24 часа",
-      "last7d": "Последние 7 дней",
+      "last7d": "7д",
       "custom": "Настраиваемый"
       "custom": "Настраиваемый"
     },
     },
     "filters": {
     "filters": {
@@ -1126,18 +1145,21 @@
         "name": "Название ключа",
         "name": "Название ключа",
         "key": "Ключ",
         "key": "Ключ",
         "group": "Группа",
         "group": "Группа",
-        "todayUsage": "Использование сегодня",
+        "todayUsage": "Запросы сегодня",
         "todayCost": "Расход сегодня",
         "todayCost": "Расход сегодня",
+        "todayTokens": "Токены сегодня",
         "lastUsed": "Последнее использование",
         "lastUsed": "Последнее использование",
         "actions": "Действия",
         "actions": "Действия",
         "quotaButton": "Просмотр использования квоты",
         "quotaButton": "Просмотр использования квоты",
         "fields": {
         "fields": {
-          "callsLabel": "Вызовы",
+          "callsLabel": "Запросы",
+          "tokensLabel": "Токены",
           "costLabel": "Расход"
           "costLabel": "Расход"
         }
         }
       },
       },
       "expand": "Развернуть",
       "expand": "Развернуть",
       "collapse": "Свернуть",
       "collapse": "Свернуть",
+      "refresh": "Обновить",
       "noKeys": "Нет ключей",
       "noKeys": "Нет ключей",
       "defaultGroup": "default",
       "defaultGroup": "default",
       "userStatus": {
       "userStatus": {
@@ -1189,6 +1211,10 @@
       "currentExpiry": "Текущий срок",
       "currentExpiry": "Текущий срок",
       "neverExpires": "Бессрочно",
       "neverExpires": "Бессрочно",
       "expired": "Истёк",
       "expired": "Истёк",
+      "quickExtensionLabel": "Быстрое продление",
+      "quickExtensionHint": "Продлить от текущего срока (или от сейчас, если истёк)",
+      "customDateLabel": "Указать дату",
+      "customDateHint": "Напрямую указать дату истечения",
       "quickOptions": {
       "quickOptions": {
         "7days": "7 дней",
         "7days": "7 дней",
         "30days": "30 дней",
         "30days": "30 дней",
@@ -1197,6 +1223,7 @@
       },
       },
       "customDate": "Произвольная дата",
       "customDate": "Произвольная дата",
       "enableOnRenew": "Также включить пользователя",
       "enableOnRenew": "Также включить пользователя",
+      "enableKeyOnRenew": "Также включить ключ",
       "cancel": "Отмена",
       "cancel": "Отмена",
       "confirm": "Подтвердить продление",
       "confirm": "Подтвердить продление",
       "confirming": "Продление...",
       "confirming": "Продление...",
@@ -1223,7 +1250,18 @@
       "userEnabled": "Пользователь активирован",
       "userEnabled": "Пользователь активирован",
       "deleteFailed": "Не удалось удалить пользователя",
       "deleteFailed": "Не удалось удалить пользователя",
       "userDeleted": "Пользователь удален",
       "userDeleted": "Пользователь удален",
-      "saving": "Сохранение..."
+      "saving": "Сохранение...",
+      "resetData": {
+        "title": "Сброс статистики",
+        "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.",
+        "error": "Не удалось сбросить данные",
+        "button": "Сбросить статистику",
+        "confirmTitle": "Сбросить всю статистику?",
+        "confirmDescription": "Это навсегда удалит все логи запросов и статистику использования для этого пользователя. Это действие нельзя отменить.",
+        "confirm": "Да, сбросить все",
+        "loading": "Сброс...",
+        "success": "Вся статистика сброшена"
+      }
     },
     },
     "batchEdit": {
     "batchEdit": {
       "enterMode": "Массовое редактирование",
       "enterMode": "Массовое редактирование",
@@ -1324,6 +1362,41 @@
     },
     },
     "limitRules": {
     "limitRules": {
       "addRule": "Добавить правило лимита",
       "addRule": "Добавить правило лимита",
+      "title": "Добавить правило лимита",
+      "description": "Выберите тип лимита и установите значение",
+      "cancel": "Отмена",
+      "confirm": "Сохранить",
+      "fields": {
+        "type": {
+          "label": "Тип лимита",
+          "placeholder": "Выберите"
+        },
+        "value": {
+          "label": "Значение",
+          "placeholder": "Введите"
+        }
+      },
+      "daily": {
+        "mode": {
+          "label": "Режим дневного сброса",
+          "fixed": "Сброс в фиксированное время",
+          "rolling": "Скользящее окно (24ч)",
+          "helperRolling": "Скользящее окно 24 часа от первого запроса"
+        },
+        "time": {
+          "label": "Время сброса",
+          "placeholder": "ЧЧ:мм"
+        }
+      },
+      "limitTypes": {
+        "limitRpm": "Лимит RPM",
+        "limit5h": "Лимит за 5 часов",
+        "limitDaily": "Дневной лимит",
+        "limitWeekly": "Недельный лимит",
+        "limitMonthly": "Месячный лимит",
+        "limitTotal": "Общий лимит",
+        "limitSessions": "Одновременные сессии"
+      },
       "ruleTypes": {
       "ruleTypes": {
         "limitRpm": "Лимит RPM",
         "limitRpm": "Лимит RPM",
         "limit5h": "Лимит за 5 часов",
         "limit5h": "Лимит за 5 часов",
@@ -1333,6 +1406,12 @@
         "limitTotal": "Общий лимит",
         "limitTotal": "Общий лимит",
         "limitSessions": "Одновременные сессии"
         "limitSessions": "Одновременные сессии"
       },
       },
+      "errors": {
+        "missingType": "Пожалуйста, выберите тип лимита",
+        "invalidValue": "Пожалуйста, введите корректное значение",
+        "invalidTime": "Пожалуйста, введите корректное время (ЧЧ:мм)"
+      },
+      "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение",
       "dailyMode": {
       "dailyMode": {
         "fixed": "Сброс по фиксированному времени",
         "fixed": "Сброс по фиксированному времени",
         "rolling": "Скользящее окно (24ч)"
         "rolling": "Скользящее окно (24ч)"
@@ -1345,8 +1424,7 @@
         "500": "$500"
         "500": "$500"
       },
       },
       "alreadySet": "Уже настроено",
       "alreadySet": "Уже настроено",
-      "confirmAdd": "Добавить",
-      "cancel": "Отмена"
+      "confirmAdd": "Добавить"
     },
     },
     "quickExpire": {
     "quickExpire": {
       "oneWeek": "Через неделю",
       "oneWeek": "Через неделю",
@@ -1535,7 +1613,9 @@
         },
         },
         "balanceQueryPage": {
         "balanceQueryPage": {
           "label": "Независимая страница использования",
           "label": "Независимая страница использования",
-          "description": "При включении этот ключ может использовать независимую страницу личного использования"
+          "description": "При включении этот ключ может использовать независимую страницу личного использования",
+          "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.",
+          "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI."
         },
         },
       "providerGroup": {
       "providerGroup": {
         "label": "Группа провайдеров",
         "label": "Группа провайдеров",
@@ -1568,6 +1648,13 @@
           }
           }
         },
         },
         "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение"
         "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение"
+      },
+      "accessRestrictions": {
+        "title": "Ограничения доступа",
+        "models": "Разрешённые модели",
+        "clients": "Разрешённые клиенты",
+        "noRestrictions": "Без ограничений",
+        "inheritedFromUser": "Унаследовано от настроек пользователя"
       }
       }
     }
     }
   },
   },

+ 40 - 5
messages/ru/quota.json

@@ -64,6 +64,8 @@
   "users": {
   "users": {
     "title": "Статистика квот пользователей",
     "title": "Статистика квот пользователей",
     "totalCount": "Всего пользователей: {count}",
     "totalCount": "Всего пользователей: {count}",
+    "manageNotice": "Для управления пользователями и ключами перейдите в",
+    "manageLink": "Управление пользователями",
     "noNote": "Без заметок",
     "noNote": "Без заметок",
     "rpm": {
     "rpm": {
       "label": "RPM квота",
       "label": "RPM квота",
@@ -85,7 +87,30 @@
       "warning": "Приближение к лимиту (>60%)",
       "warning": "Приближение к лимиту (>60%)",
       "exceeded": "Превышено (≥100%)"
       "exceeded": "Превышено (≥100%)"
     },
     },
-    "expiresAtLabel": "Срок действия"
+    "withQuotas": "С квотами",
+    "unlimited": "Без ограничений",
+    "totalCost": "Общие расходы",
+    "totalCostAllTime": "Всего за все время",
+    "todayCost": "Расходы за сегодня",
+    "expiresAtLabel": "Срок действия",
+    "keys": "Ключи",
+    "more": "ещё",
+    "noLimitSet": "-",
+    "noUnlimited": "Нет пользователей без ограничений",
+    "noKeys": "Нет ключей",
+    "limit5h": "Лимит 5 часов",
+    "limitWeekly": "Недельный лимит",
+    "limitMonthly": "Месячный лимит",
+    "limitTotal": "Общий лимит",
+    "limitConcurrent": "Параллельные сессии",
+    "role": {
+      "admin": "Администратор",
+      "user": "Пользователь"
+    },
+    "keyStatus": {
+      "enabled": "Включен",
+      "disabled": "Отключен"
+    }
   },
   },
   "providers": {
   "providers": {
     "title": "Статистика квот провайдеров",
     "title": "Статистика квот провайдеров",
@@ -263,7 +288,8 @@
       "limit5hUsd": {
       "limit5hUsd": {
         "label": "Лимит расходов за 5 часов (USD)",
         "label": "Лимит расходов за 5 часов (USD)",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
-        "description": "Максимальная сумма расходов за 5 часов"
+        "description": "Максимальная сумма расходов за 5 часов",
+        "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})"
       },
       },
       "limitDailyUsd": {
       "limitDailyUsd": {
         "label": "Дневной лимит расходов (USD)",
         "label": "Дневной лимит расходов (USD)",
@@ -289,17 +315,26 @@
       "limitWeeklyUsd": {
       "limitWeeklyUsd": {
         "label": "Еженедельный лимит расходов (USD)",
         "label": "Еженедельный лимит расходов (USD)",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
-        "description": "Максимальная сумма расходов в неделю"
+        "description": "Максимальная сумма расходов в неделю",
+        "descriptionWithUserLimit": "Не может превышать недельный лимит пользователя ({limit})"
       },
       },
       "limitMonthlyUsd": {
       "limitMonthlyUsd": {
         "label": "Ежемесячный лимит расходов (USD)",
         "label": "Ежемесячный лимит расходов (USD)",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
         "placeholder": "Оставьте пустым для отсутствия ограничений",
-        "description": "Максимальная сумма расходов в месяц"
+        "description": "Максимальная сумма расходов в месяц",
+        "descriptionWithUserLimit": "Не может превышать месячный лимит пользователя ({limit})"
+      },
+      "limitTotalUsd": {
+        "label": "Общий лимит расходов (USD)",
+        "placeholder": "Оставьте пустым для отсутствия ограничений",
+        "description": "Максимальная сумма расходов (без сброса)",
+        "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})"
       },
       },
       "limitConcurrentSessions": {
       "limitConcurrentSessions": {
         "label": "Лимит параллельных сессий",
         "label": "Лимит параллельных сессий",
         "placeholder": "0 = без ограничений",
         "placeholder": "0 = без ограничений",
-        "description": "Количество одновременных диалогов"
+        "description": "Количество одновременных диалогов",
+        "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})"
       },
       },
       "providerGroup": {
       "providerGroup": {
         "label": "Группа провайдеров",
         "label": "Группа провайдеров",

+ 1 - 1
messages/ru/settings/providers/autoSort.json

@@ -1,5 +1,5 @@
 {
 {
-  "button": "Авто сортировка приоритета",
+  "button": "Автосорт",
   "changeCount": "{count} поставщиков будет обновлено",
   "changeCount": "{count} поставщиков будет обновлено",
   "changesTitle": "Детали изменений",
   "changesTitle": "Детали изменений",
   "confirm": "Применить изменения",
   "confirm": "Применить изменения",

+ 2 - 2
messages/ru/settings/providers/form/title.json

@@ -1,4 +1,4 @@
 {
 {
-  "create": "Добавить провайдера",
-  "edit": "Редактировать провайдера"
+  "create": "Добавить поставщика",
+  "edit": "Редактировать поставщика"
 }
 }

+ 2 - 2
messages/ru/settings/providers/strings.json

@@ -1,7 +1,7 @@
 {
 {
   "add": "Добавить поставщика",
   "add": "Добавить поставщика",
   "addFailed": "Ошибка добавления поставщика",
   "addFailed": "Ошибка добавления поставщика",
-  "addProvider": "Добавить провайдера",
+  "addProvider": "Добавить поставщика",
   "addSuccess": "Поставщик добавлен успешно",
   "addSuccess": "Поставщик добавлен успешно",
   "circuitBroken": "Цепь разомкнута",
   "circuitBroken": "Цепь разомкнута",
   "clone": "Дублировать поставщика",
   "clone": "Дублировать поставщика",
@@ -10,7 +10,7 @@
   "confirmDeleteDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть отменено.",
   "confirmDeleteDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть отменено.",
   "confirmDeleteProvider": "Подтвердить удаление провайдера?",
   "confirmDeleteProvider": "Подтвердить удаление провайдера?",
   "confirmDeleteProviderDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть восстановлено.",
   "confirmDeleteProviderDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть восстановлено.",
-  "createProvider": "Добавить провайдера",
+  "createProvider": "Добавить поставщика",
   "delete": "Удалить поставщика",
   "delete": "Удалить поставщика",
   "deleteFailed": "Ошибка удаления поставщика",
   "deleteFailed": "Ошибка удаления поставщика",
   "deleteSuccess": "Успешно удалено",
   "deleteSuccess": "Успешно удалено",

+ 5 - 2
messages/zh-CN/auth.json

@@ -19,7 +19,10 @@
     "invalidToken": "无效的认证令牌",
     "invalidToken": "无效的认证令牌",
     "tokenRequired": "需要提供认证令牌",
     "tokenRequired": "需要提供认证令牌",
     "sessionExpired": "会话已过期,请重新登录",
     "sessionExpired": "会话已过期,请重新登录",
-    "unauthorized": "未授权,请先登录"
+    "unauthorized": "未授权,请先登录",
+    "apiKeyRequired": "请输入 API Key",
+    "apiKeyInvalidOrExpired": "API Key 无效或已过期",
+    "serverError": "登录失败,请稍后重试"
   },
   },
   "placeholders": {
   "placeholders": {
     "apiKeyExample": "例如 sk-xxxxxxxx"
     "apiKeyExample": "例如 sk-xxxxxxxx"
@@ -34,7 +37,7 @@
     "solutionTitle": "解决方案:",
     "solutionTitle": "解决方案:",
     "useHttps": "使用 HTTPS 访问(推荐)",
     "useHttps": "使用 HTTPS 访问(推荐)",
     "disableSecureCookies": "在 .env 中设置 ENABLE_SECURE_COOKIES=false(会降低安全性)",
     "disableSecureCookies": "在 .env 中设置 ENABLE_SECURE_COOKIES=false(会降低安全性)",
-    "privacyNote": "请使用您的 API Key 登录 Claude Code Hub 后台"
+    "privacyNote": "请使用您的 API Key 登录后台"
   },
   },
   "form": {
   "form": {
     "title": "登录面板",
     "title": "登录面板",

+ 11 - 1
messages/zh-CN/common.json

@@ -48,5 +48,15 @@
   "theme": "主题",
   "theme": "主题",
   "light": "浅色",
   "light": "浅色",
   "dark": "深色",
   "dark": "深色",
-  "system": "跟随系统"
+  "system": "跟随系统",
+  "relativeTimeShort": {
+    "now": "刚刚",
+    "secondsAgo": "{count}秒前",
+    "minutesAgo": "{count}分前",
+    "hoursAgo": "{count}时前",
+    "daysAgo": "{count}天前",
+    "weeksAgo": "{count}周前",
+    "monthsAgo": "{count}月前",
+    "yearsAgo": "{count}年前"
+  }
 }
 }

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

@@ -786,6 +786,15 @@
       "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商",
       "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商",
       "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})"
       "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})"
     },
     },
+    "cacheTtl": {
+      "label": "Cache TTL 覆写",
+      "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。",
+      "options": {
+        "inherit": "不覆写(跟随供应商/客户端)",
+        "5m": "5m",
+        "1h": "1h"
+      }
+    },
     "successTitle": "密钥创建成功",
     "successTitle": "密钥创建成功",
     "successDescription": "您的 API 密钥已成功创建。",
     "successDescription": "您的 API 密钥已成功创建。",
     "generatedKey": {
     "generatedKey": {
@@ -1149,18 +1158,21 @@
         "name": "密钥名称",
         "name": "密钥名称",
         "key": "密钥",
         "key": "密钥",
         "group": "分组",
         "group": "分组",
-        "todayUsage": "今日用量",
+        "todayUsage": "今日请求",
         "todayCost": "今日消耗",
         "todayCost": "今日消耗",
+        "todayTokens": "今日Token",
         "lastUsed": "最后使用",
         "lastUsed": "最后使用",
         "actions": "操作",
         "actions": "操作",
         "quotaButton": "查看限额用量",
         "quotaButton": "查看限额用量",
         "fields": {
         "fields": {
-          "callsLabel": "调用",
+          "callsLabel": "请求",
+          "tokensLabel": "Token",
           "costLabel": "消耗"
           "costLabel": "消耗"
         }
         }
       },
       },
       "expand": "展开",
       "expand": "展开",
       "collapse": "收起",
       "collapse": "收起",
+      "refresh": "刷新",
       "noKeys": "无密钥",
       "noKeys": "无密钥",
       "defaultGroup": "default",
       "defaultGroup": "default",
       "userStatus": {
       "userStatus": {
@@ -1251,7 +1263,18 @@
       "userEnabled": "用户已启用",
       "userEnabled": "用户已启用",
       "deleteFailed": "删除用户失败",
       "deleteFailed": "删除用户失败",
       "userDeleted": "用户已删除",
       "userDeleted": "用户已删除",
-      "saving": "保存中..."
+      "saving": "保存中...",
+      "resetData": {
+        "title": "重置统计",
+        "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。",
+        "error": "重置数据失败",
+        "button": "重置统计",
+        "confirmTitle": "重置所有统计?",
+        "confirmDescription": "这将永久删除该用户的所有请求日志和使用统计。此操作无法撤销。",
+        "confirm": "是的,重置全部",
+        "loading": "重置中...",
+        "success": "所有统计已重置"
+      }
     },
     },
     "batchEdit": {
     "batchEdit": {
       "enterMode": "批量编辑",
       "enterMode": "批量编辑",

+ 5 - 2
messages/zh-TW/auth.json

@@ -30,7 +30,7 @@
     "solutionTitle": "解決方案:",
     "solutionTitle": "解決方案:",
     "useHttps": "使用 HTTPS 存取(推薦)",
     "useHttps": "使用 HTTPS 存取(推薦)",
     "disableSecureCookies": "在 .env 中設定 ENABLE_SECURE_COOKIES=false(會降低安全性)",
     "disableSecureCookies": "在 .env 中設定 ENABLE_SECURE_COOKIES=false(會降低安全性)",
-    "privacyNote": "請使用您的 API Key 登入 Claude Code Hub 後台"
+    "privacyNote": "請使用您的 API Key 登入後台"
   },
   },
   "errors": {
   "errors": {
     "loginFailed": "登錄失敗",
     "loginFailed": "登錄失敗",
@@ -38,6 +38,9 @@
     "invalidToken": "無效的認證令牌",
     "invalidToken": "無效的認證令牌",
     "tokenRequired": "需要提供認證令牌",
     "tokenRequired": "需要提供認證令牌",
     "sessionExpired": "會話已過期,請重新登錄",
     "sessionExpired": "會話已過期,請重新登錄",
-    "unauthorized": "未授權,請先登錄"
+    "unauthorized": "未授權,請先登錄",
+    "apiKeyRequired": "請輸入 API Key",
+    "apiKeyInvalidOrExpired": "API Key 無效或已過期",
+    "serverError": "登錄失敗,請稍後重試"
   }
   }
 }
 }

+ 11 - 1
messages/zh-TW/common.json

@@ -48,5 +48,15 @@
   "theme": "主題",
   "theme": "主題",
   "light": "淺色",
   "light": "淺色",
   "dark": "深色",
   "dark": "深色",
-  "system": "跟隨系統"
+  "system": "跟隨系統",
+  "relativeTimeShort": {
+    "now": "剛剛",
+    "secondsAgo": "{count}秒前",
+    "minutesAgo": "{count}分前",
+    "hoursAgo": "{count}時前",
+    "daysAgo": "{count}天前",
+    "weeksAgo": "{count}週前",
+    "monthsAgo": "{count}月前",
+    "yearsAgo": "{count}年前"
+  }
 }
 }

+ 45 - 7
messages/zh-TW/dashboard.json

@@ -721,7 +721,8 @@
     "limit5hUsd": {
     "limit5hUsd": {
       "label": "5小時消費上限(USD)",
       "label": "5小時消費上限(USD)",
       "placeholder": "留空表示無限制",
       "placeholder": "留空表示無限制",
-      "description": "5小時內最大消費金額"
+      "description": "5小時內最大消費金額",
+      "descriptionWithUserLimit": "5小時內最大消費金額(使用者上限:{limit})"
     },
     },
     "limitDailyUsd": {
     "limitDailyUsd": {
       "label": "每日消費上限(USD)",
       "label": "每日消費上限(USD)",
@@ -747,17 +748,26 @@
     "limitWeeklyUsd": {
     "limitWeeklyUsd": {
       "label": "週消費上限(USD)",
       "label": "週消費上限(USD)",
       "placeholder": "留空表示無限制",
       "placeholder": "留空表示無限制",
-      "description": "每週最大消費金額"
+      "description": "每週最大消費金額",
+      "descriptionWithUserLimit": "每週最大消費金額(使用者上限:{limit})"
     },
     },
     "limitMonthlyUsd": {
     "limitMonthlyUsd": {
       "label": "月消費上限(USD)",
       "label": "月消費上限(USD)",
       "placeholder": "留空表示無限制",
       "placeholder": "留空表示無限制",
-      "description": "每月最大消費金額"
+      "description": "每月最大消費金額",
+      "descriptionWithUserLimit": "每月最大消費金額(使用者上限:{limit})"
+    },
+    "limitTotalUsd": {
+      "label": "總消費上限(USD)",
+      "placeholder": "留空表示無限制",
+      "description": "累計消費上限(不重置)",
+      "descriptionWithUserLimit": "不能超過使用者總限額({limit})"
     },
     },
     "limitConcurrentSessions": {
     "limitConcurrentSessions": {
       "label": "並發 Session 上限",
       "label": "並發 Session 上限",
       "placeholder": "0 表示無限制",
       "placeholder": "0 表示無限制",
-      "description": "同時執行的對話數量"
+      "description": "同時執行的對話數量",
+      "descriptionWithUserLimit": "最大 Session 數(使用者上限:{limit})"
     },
     },
     "providerGroup": {
     "providerGroup": {
       "label": "供應商分組",
       "label": "供應商分組",
@@ -766,6 +776,15 @@
       "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商",
       "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商",
       "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})"
       "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})"
     },
     },
+    "cacheTtl": {
+      "label": "Cache TTL 覆寫",
+      "description": "強制為包含 cache_control 的請求設定 Anthropic prompt cache TTL。",
+      "options": {
+        "inherit": "不覆寫(跟隨供應商/客戶端)",
+        "5m": "5m",
+        "1h": "1h"
+      }
+    },
     "successTitle": "金鑰建立成功",
     "successTitle": "金鑰建立成功",
     "successDescription": "您的 API 金鑰已成功建立。",
     "successDescription": "您的 API 金鑰已成功建立。",
     "generatedKey": {
     "generatedKey": {
@@ -1124,18 +1143,21 @@
         "name": "金鑰名稱",
         "name": "金鑰名稱",
         "key": "金鑰",
         "key": "金鑰",
         "group": "分組",
         "group": "分組",
-        "todayUsage": "今日使用量",
+        "todayUsage": "今日請求",
         "todayCost": "今日花費",
         "todayCost": "今日花費",
+        "todayTokens": "今日Token",
         "lastUsed": "最後使用",
         "lastUsed": "最後使用",
         "actions": "動作",
         "actions": "動作",
         "quotaButton": "查看限額用量",
         "quotaButton": "查看限額用量",
         "fields": {
         "fields": {
-          "callsLabel": "今日呼叫",
+          "callsLabel": "請求",
+          "tokensLabel": "Token",
           "costLabel": "今日消耗"
           "costLabel": "今日消耗"
         }
         }
       },
       },
       "expand": "展開",
       "expand": "展開",
       "collapse": "摺疊",
       "collapse": "摺疊",
+      "refresh": "重新整理",
       "noKeys": "無金鑰",
       "noKeys": "無金鑰",
       "defaultGroup": "default",
       "defaultGroup": "default",
       "userStatus": {
       "userStatus": {
@@ -1187,6 +1209,10 @@
       "currentExpiry": "目前到期時間",
       "currentExpiry": "目前到期時間",
       "neverExpires": "永不過期",
       "neverExpires": "永不過期",
       "expired": "已過期",
       "expired": "已過期",
+      "quickExtensionLabel": "快速延期",
+      "quickExtensionHint": "從目前到期日延長(若已過期則從現在開始)",
+      "customDateLabel": "設定到期日",
+      "customDateHint": "直接指定到期日期",
       "quickOptions": {
       "quickOptions": {
         "7days": "7天",
         "7days": "7天",
         "30days": "30天",
         "30days": "30天",
@@ -1195,6 +1221,7 @@
       },
       },
       "customDate": "自訂日期",
       "customDate": "自訂日期",
       "enableOnRenew": "同時啟用使用者",
       "enableOnRenew": "同時啟用使用者",
+      "enableKeyOnRenew": "同時啟用金鑰",
       "cancel": "取消續期",
       "cancel": "取消續期",
       "confirm": "確認續期",
       "confirm": "確認續期",
       "confirming": "續期中...",
       "confirming": "續期中...",
@@ -1221,7 +1248,18 @@
       "userEnabled": "使用者已啟用",
       "userEnabled": "使用者已啟用",
       "deleteFailed": "刪除使用者失敗",
       "deleteFailed": "刪除使用者失敗",
       "userDeleted": "使用者已刪除",
       "userDeleted": "使用者已刪除",
-      "saving": "儲存中..."
+      "saving": "儲存中...",
+      "resetData": {
+        "title": "重置統計",
+        "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。",
+        "error": "重置資料失敗",
+        "button": "重置統計",
+        "confirmTitle": "重置所有統計?",
+        "confirmDescription": "這將永久刪除該使用者的所有請求日誌和使用統計。此操作無法撤銷。",
+        "confirm": "是的,重置全部",
+        "loading": "重置中...",
+        "success": "所有統計已重置"
+      }
     },
     },
     "batchEdit": {
     "batchEdit": {
       "enterMode": "批量編輯",
       "enterMode": "批量編輯",

+ 14 - 4
messages/zh-TW/quota.json

@@ -263,7 +263,8 @@
       "limit5hUsd": {
       "limit5hUsd": {
         "label": "5小時消費上限 (USD)",
         "label": "5小時消費上限 (USD)",
         "placeholder": "留空表示無限制",
         "placeholder": "留空表示無限制",
-        "description": "5小時內最大消費金額"
+        "description": "5小時內最大消費金額",
+        "descriptionWithUserLimit": "不能超過使用者5小時限額 ({limit})"
       },
       },
       "limitDailyUsd": {
       "limitDailyUsd": {
         "label": "每日消費上限 (USD)",
         "label": "每日消費上限 (USD)",
@@ -289,17 +290,26 @@
       "limitWeeklyUsd": {
       "limitWeeklyUsd": {
         "label": "週消費上限 (USD)",
         "label": "週消費上限 (USD)",
         "placeholder": "留空表示無限制",
         "placeholder": "留空表示無限制",
-        "description": "每週最大消費金額"
+        "description": "每週最大消費金額",
+        "descriptionWithUserLimit": "不能超過使用者週限額 ({limit})"
       },
       },
       "limitMonthlyUsd": {
       "limitMonthlyUsd": {
         "label": "月消費上限 (USD)",
         "label": "月消費上限 (USD)",
         "placeholder": "留空表示無限制",
         "placeholder": "留空表示無限制",
-        "description": "每月最大消費金額"
+        "description": "每月最大消費金額",
+        "descriptionWithUserLimit": "不能超過使用者月限額 ({limit})"
+      },
+      "limitTotalUsd": {
+        "label": "總消費上限 (USD)",
+        "placeholder": "留空表示無限制",
+        "description": "累計消費上限(不重置)",
+        "descriptionWithUserLimit": "不能超過使用者總限額 ({limit})"
       },
       },
       "limitConcurrentSessions": {
       "limitConcurrentSessions": {
         "label": "並發 Session 上限",
         "label": "並發 Session 上限",
         "placeholder": "0 表示無限制",
         "placeholder": "0 表示無限制",
-        "description": "同時運行的對話數量"
+        "description": "同時運行的對話數量",
+        "descriptionWithUserLimit": "不能超過使用者並發限額 ({limit})"
       },
       },
       "providerGroup": {
       "providerGroup": {
         "label": "供應商分組",
         "label": "供應商分組",

+ 6 - 4
src/actions/key-quota.ts

@@ -28,11 +28,8 @@ export interface KeyQuotaUsageResult {
 
 
 export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQuotaUsageResult>> {
 export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQuotaUsageResult>> {
   try {
   try {
-    const session = await getSession();
+    const session = await getSession({ allowReadOnlyAccess: true });
     if (!session) return { ok: false, error: "Unauthorized" };
     if (!session) return { ok: false, error: "Unauthorized" };
-    if (session.user.role !== "admin") {
-      return { ok: false, error: "Admin access required" };
-    }
 
 
     const [keyRow] = await db
     const [keyRow] = await db
       .select()
       .select()
@@ -44,6 +41,11 @@ export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQ
       return { ok: false, error: "Key not found" };
       return { ok: false, error: "Key not found" };
     }
     }
 
 
+    // Allow admin to view any key, users can only view their own keys
+    if (session.user.role !== "admin" && keyRow.userId !== session.user.id) {
+      return { ok: false, error: "Access denied" };
+    }
+
     const settings = await getSystemSettings();
     const settings = await getSystemSettings();
     const currencyCode = settings.currencyDisplay;
     const currencyCode = settings.currencyDisplay;
 
 

+ 130 - 6
src/actions/users.ts

@@ -1,11 +1,11 @@
 "use server";
 "use server";
 
 
 import { randomBytes } from "node:crypto";
 import { randomBytes } from "node:crypto";
-import { and, inArray, isNull } from "drizzle-orm";
+import { and, eq, inArray, isNull } from "drizzle-orm";
 import { revalidatePath } from "next/cache";
 import { revalidatePath } from "next/cache";
 import { getLocale, getTranslations } from "next-intl/server";
 import { getLocale, getTranslations } from "next-intl/server";
 import { db } from "@/drizzle/db";
 import { db } from "@/drizzle/db";
-import { users as usersTable } from "@/drizzle/schema";
+import { messageRequest, users as usersTable } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
 import { getSession } from "@/lib/auth";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { logger } from "@/lib/logger";
 import { logger } from "@/lib/logger";
@@ -210,7 +210,12 @@ export async function getUsers(): Promise<UserDisplay[]> {
         const usageRecords = usageMap.get(user.id) || [];
         const usageRecords = usageMap.get(user.id) || [];
         const keyStatistics = statisticsMap.get(user.id) || [];
         const keyStatistics = statisticsMap.get(user.id) || [];
 
 
-        const usageLookup = new Map(usageRecords.map((item) => [item.keyId, item.totalCost ?? 0]));
+        const usageLookup = new Map(
+          usageRecords.map((item) => [
+            item.keyId,
+            { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 },
+          ])
+        );
         const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat]));
         const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat]));
 
 
         return {
         return {
@@ -256,7 +261,8 @@ export async function getUsers(): Promise<UserDisplay[]> {
                 minute: "2-digit",
                 minute: "2-digit",
                 second: "2-digit",
                 second: "2-digit",
               }),
               }),
-              todayUsage: usageLookup.get(key.id) ?? 0,
+              todayUsage: usageLookup.get(key.id)?.totalCost ?? 0,
+              todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0,
               todayCallCount: stats?.todayCallCount ?? 0,
               todayCallCount: stats?.todayCallCount ?? 0,
               lastUsedAt: stats?.lastUsedAt ?? null,
               lastUsedAt: stats?.lastUsedAt ?? null,
               lastProviderName: stats?.lastProviderName ?? null,
               lastProviderName: stats?.lastProviderName ?? null,
@@ -473,7 +479,12 @@ export async function getUsersBatch(
         const usageRecords = usageMap.get(user.id) || [];
         const usageRecords = usageMap.get(user.id) || [];
         const keyStatistics = statisticsMap.get(user.id) || [];
         const keyStatistics = statisticsMap.get(user.id) || [];
 
 
-        const usageLookup = new Map(usageRecords.map((item) => [item.keyId, item.totalCost ?? 0]));
+        const usageLookup = new Map(
+          usageRecords.map((item) => [
+            item.keyId,
+            { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 },
+          ])
+        );
         const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat]));
         const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat]));
 
 
         return {
         return {
@@ -517,7 +528,8 @@ export async function getUsersBatch(
                 minute: "2-digit",
                 minute: "2-digit",
                 second: "2-digit",
                 second: "2-digit",
               }),
               }),
-              todayUsage: usageLookup.get(key.id) ?? 0,
+              todayUsage: usageLookup.get(key.id)?.totalCost ?? 0,
+              todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0,
               todayCallCount: stats?.todayCallCount ?? 0,
               todayCallCount: stats?.todayCallCount ?? 0,
               lastUsedAt: stats?.lastUsedAt ?? null,
               lastUsedAt: stats?.lastUsedAt ?? null,
               lastProviderName: stats?.lastProviderName ?? null,
               lastProviderName: stats?.lastProviderName ?? null,
@@ -1496,3 +1508,115 @@ export async function getUserAllLimitUsage(userId: number): Promise<
     return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED };
     return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED };
   }
   }
 }
 }
+
+/**
+ * Reset ALL user statistics (logs + Redis cache + sessions)
+ * This is IRREVERSIBLE - deletes all messageRequest logs for the user
+ *
+ * Admin only.
+ */
+export async function resetUserAllStatistics(userId: number): Promise<ActionResult> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    const user = await findUserById(userId);
+    if (!user) {
+      return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND };
+    }
+
+    // Get user's keys
+    const keys = await findKeyList(userId);
+    const keyIds = keys.map((k) => k.id);
+
+    // 1. Delete all messageRequest logs for this user
+    await db.delete(messageRequest).where(eq(messageRequest.userId, userId));
+
+    // 2. Clear Redis cache
+    const { getRedisClient } = await import("@/lib/redis");
+    const { scanPattern } = await import("@/lib/redis/scan-helper");
+    const redis = getRedisClient();
+
+    if (redis && redis.status === "ready") {
+      try {
+        const startTime = Date.now();
+
+        // Scan all patterns in parallel
+        const scanResults = await Promise.all([
+          ...keyIds.map((keyId) =>
+            scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => {
+              logger.warn("Failed to scan key cost pattern", { keyId, error: err });
+              return [];
+            })
+          ),
+          scanPattern(redis, `user:${userId}:cost_*`).catch((err) => {
+            logger.warn("Failed to scan user cost pattern", { userId, error: err });
+            return [];
+          }),
+        ]);
+
+        const allCostKeys = scanResults.flat();
+
+        // Batch delete via pipeline
+        const pipeline = redis.pipeline();
+
+        // Active sessions
+        for (const keyId of keyIds) {
+          pipeline.del(`key:${keyId}:active_sessions`);
+        }
+
+        // Cost keys
+        for (const key of allCostKeys) {
+          pipeline.del(key);
+        }
+
+        const results = await pipeline.exec();
+
+        // Check for errors
+        const errors = results?.filter(([err]) => err);
+        if (errors && errors.length > 0) {
+          logger.warn("Some Redis deletes failed during user statistics reset", {
+            errorCount: errors.length,
+            userId,
+          });
+        }
+
+        const duration = Date.now() - startTime;
+        logger.info("Reset user statistics - Redis cache cleared", {
+          userId,
+          keyCount: keyIds.length,
+          costKeysDeleted: allCostKeys.length,
+          activeSessionsDeleted: keyIds.length,
+          durationMs: duration,
+        });
+      } catch (error) {
+        logger.error("Failed to clear Redis cache during user statistics reset", {
+          userId,
+          error: error instanceof Error ? error.message : String(error),
+        });
+        // Continue execution - DB logs already deleted
+      }
+    }
+
+    logger.info("Reset all user statistics", { userId, keyCount: keyIds.length });
+    revalidatePath("/dashboard/users");
+
+    return { ok: true };
+  } catch (error) {
+    logger.error("Failed to reset all user statistics:", error);
+    const tError = await getTranslations("errors");
+    return {
+      ok: false,
+      error: tError("OPERATION_FAILED"),
+      errorCode: ERROR_CODES.OPERATION_FAILED,
+    };
+  }
+}

+ 8 - 8
src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx

@@ -70,14 +70,14 @@ export function AddKeyDialog({
 
 
   return (
   return (
     <Dialog open={open} onOpenChange={handleClose}>
     <Dialog open={open} onOpenChange={handleClose}>
-      <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+      <DialogContent className="max-w-2xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
         {generatedKey ? (
         {generatedKey ? (
           <>
           <>
-            <DialogHeader>
+            <DialogHeader className="px-6 pt-6 pb-4 border-b">
               <DialogTitle>{t("successTitle")}</DialogTitle>
               <DialogTitle>{t("successTitle")}</DialogTitle>
               <DialogDescription>{t("successDescription")}</DialogDescription>
               <DialogDescription>{t("successDescription")}</DialogDescription>
             </DialogHeader>
             </DialogHeader>
-            <div className="space-y-4 py-4">
+            <div className="space-y-4 p-6">
               <div className="space-y-2">
               <div className="space-y-2">
                 <Label>{t("keyName.label")}</Label>
                 <Label>{t("keyName.label")}</Label>
                 <Input value={generatedKey.name} readOnly className="bg-muted" />
                 <Input value={generatedKey.name} readOnly className="bg-muted" />
@@ -106,11 +106,11 @@ export function AddKeyDialog({
                 </div>
                 </div>
                 <p className="text-xs text-muted-foreground">{t("generatedKey.hint")}</p>
                 <p className="text-xs text-muted-foreground">{t("generatedKey.hint")}</p>
               </div>
               </div>
-              <div className="flex justify-end pt-4">
-                <Button type="button" onClick={handleClose}>
-                  {tCommon("close")}
-                </Button>
-              </div>
+            </div>
+            <div className="flex justify-end px-6 py-4 border-t">
+              <Button type="button" onClick={handleClose}>
+                {tCommon("close")}
+              </Button>
             </div>
             </div>
           </>
           </>
         ) : (
         ) : (

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

@@ -376,6 +376,8 @@ function BatchEditDialogInner({
 
 
       if (anySuccess) {
       if (anySuccess) {
         await queryClient.invalidateQueries({ queryKey: ["users"] });
         await queryClient.invalidateQueries({ queryKey: ["users"] });
+        await queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+        await queryClient.invalidateQueries({ queryKey: ["userTags"] });
       }
       }
 
 
       // Only close dialog and clear selection when fully successful
       // Only close dialog and clear selection when fully successful

+ 1 - 1
src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx

@@ -52,7 +52,7 @@ export function EditKeyDialog({
 
 
   return (
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+      <DialogContent className="max-w-2xl max-h-[90dvh] p-0 flex flex-col overflow-hidden">
         <DialogHeader className="sr-only">
         <DialogHeader className="sr-only">
           <DialogTitle>{t("title")}</DialogTitle>
           <DialogTitle>{t("title")}</DialogTitle>
           <DialogDescription>{t("description")}</DialogDescription>
           <DialogDescription>{t("description")}</DialogDescription>

+ 100 - 4
src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx

@@ -1,14 +1,25 @@
 "use client";
 "use client";
 
 
 import { useQueryClient } from "@tanstack/react-query";
 import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, UserCog } from "lucide-react";
+import { Loader2, Trash2, UserCog } from "lucide-react";
 import { useRouter } from "next/navigation";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
-import { useMemo, useTransition } from "react";
+import { useMemo, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { toast } from "sonner";
 import { z } from "zod";
 import { z } from "zod";
-import { editUser, removeUser, toggleUserEnabled } from "@/actions/users";
-import { Button } from "@/components/ui/button";
+import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button, buttonVariants } from "@/components/ui/button";
 import {
 import {
   Dialog,
   Dialog,
   DialogContent,
   DialogContent,
@@ -18,6 +29,7 @@ import {
   DialogTitle,
   DialogTitle,
 } from "@/components/ui/dialog";
 } from "@/components/ui/dialog";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { cn } from "@/lib/utils";
 import { UpdateUserSchema } from "@/lib/validation/schemas";
 import { UpdateUserSchema } from "@/lib/validation/schemas";
 import type { UserDisplay } from "@/types/user";
 import type { UserDisplay } from "@/types/user";
 import { DangerZone } from "./forms/danger-zone";
 import { DangerZone } from "./forms/danger-zone";
@@ -71,6 +83,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
   const t = useTranslations("dashboard.userManagement");
   const t = useTranslations("dashboard.userManagement");
   const tCommon = useTranslations("common");
   const tCommon = useTranslations("common");
   const [isPending, startTransition] = useTransition();
   const [isPending, startTransition] = useTransition();
+  const [isResettingAll, setIsResettingAll] = useState(false);
+  const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false);
 
 
   // Always show providerGroup field in edit mode
   // Always show providerGroup field in edit mode
   const userEditTranslations = useUserTranslations({ showProviderGroup: true });
   const userEditTranslations = useUserTranslations({ showProviderGroup: true });
@@ -110,6 +124,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
           onSuccess?.();
           onSuccess?.();
           onOpenChange(false);
           onOpenChange(false);
           queryClient.invalidateQueries({ queryKey: ["users"] });
           queryClient.invalidateQueries({ queryKey: ["users"] });
+          queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+          queryClient.invalidateQueries({ queryKey: ["userTags"] });
           router.refresh();
           router.refresh();
         } catch (error) {
         } catch (error) {
           console.error("[EditUserDialog] submit failed", error);
           console.error("[EditUserDialog] submit failed", error);
@@ -161,6 +177,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
       toast.success(t("editDialog.userDisabled"));
       toast.success(t("editDialog.userDisabled"));
       onSuccess?.();
       onSuccess?.();
       queryClient.invalidateQueries({ queryKey: ["users"] });
       queryClient.invalidateQueries({ queryKey: ["users"] });
+      queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+      queryClient.invalidateQueries({ queryKey: ["userTags"] });
       router.refresh();
       router.refresh();
     } catch (error) {
     } catch (error) {
       console.error("[EditUserDialog] disable user failed", error);
       console.error("[EditUserDialog] disable user failed", error);
@@ -178,6 +196,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
       toast.success(t("editDialog.userEnabled"));
       toast.success(t("editDialog.userEnabled"));
       onSuccess?.();
       onSuccess?.();
       queryClient.invalidateQueries({ queryKey: ["users"] });
       queryClient.invalidateQueries({ queryKey: ["users"] });
+      queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+      queryClient.invalidateQueries({ queryKey: ["userTags"] });
       router.refresh();
       router.refresh();
     } catch (error) {
     } catch (error) {
       console.error("[EditUserDialog] enable user failed", error);
       console.error("[EditUserDialog] enable user failed", error);
@@ -194,9 +214,32 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
     onSuccess?.();
     onSuccess?.();
     onOpenChange(false);
     onOpenChange(false);
     queryClient.invalidateQueries({ queryKey: ["users"] });
     queryClient.invalidateQueries({ queryKey: ["users"] });
+    queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+    queryClient.invalidateQueries({ queryKey: ["userTags"] });
     router.refresh();
     router.refresh();
   };
   };
 
 
+  const handleResetAllStatistics = async () => {
+    setIsResettingAll(true);
+    try {
+      const res = await resetUserAllStatistics(user.id);
+      if (!res.ok) {
+        toast.error(res.error || t("editDialog.resetData.error"));
+        return;
+      }
+      toast.success(t("editDialog.resetData.success"));
+      setResetAllDialogOpen(false);
+
+      // Full page reload to ensure all cached data is refreshed
+      window.location.reload();
+    } catch (error) {
+      console.error("[EditUserDialog] reset all statistics failed", error);
+      toast.error(t("editDialog.resetData.error"));
+    } finally {
+      setIsResettingAll(false);
+    }
+  };
+
   return (
   return (
     <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
     <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
       <form onSubmit={form.handleSubmit} className="flex flex-1 min-h-0 flex-col">
       <form onSubmit={form.handleSubmit} className="flex flex-1 min-h-0 flex-col">
@@ -243,6 +286,59 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
             modelSuggestions={modelSuggestions}
             modelSuggestions={modelSuggestions}
           />
           />
 
 
+          {/* Reset Data Section - Admin Only */}
+          <section className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
+            <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+              <div className="space-y-1">
+                <h3 className="text-sm font-medium text-destructive">
+                  {t("editDialog.resetData.title")}
+                </h3>
+                <p className="text-xs text-muted-foreground">
+                  {t("editDialog.resetData.description")}
+                </p>
+              </div>
+
+              <AlertDialog open={resetAllDialogOpen} onOpenChange={setResetAllDialogOpen}>
+                <AlertDialogTrigger asChild>
+                  <Button type="button" variant="destructive">
+                    <Trash2 className="h-4 w-4" />
+                    {t("editDialog.resetData.button")}
+                  </Button>
+                </AlertDialogTrigger>
+                <AlertDialogContent>
+                  <AlertDialogHeader>
+                    <AlertDialogTitle>{t("editDialog.resetData.confirmTitle")}</AlertDialogTitle>
+                    <AlertDialogDescription>
+                      {t("editDialog.resetData.confirmDescription")}
+                    </AlertDialogDescription>
+                  </AlertDialogHeader>
+                  <AlertDialogFooter>
+                    <AlertDialogCancel disabled={isResettingAll}>
+                      {tCommon("cancel")}
+                    </AlertDialogCancel>
+                    <AlertDialogAction
+                      onClick={(e) => {
+                        e.preventDefault();
+                        handleResetAllStatistics();
+                      }}
+                      disabled={isResettingAll}
+                      className={cn(buttonVariants({ variant: "destructive" }))}
+                    >
+                      {isResettingAll ? (
+                        <>
+                          <Loader2 className="h-4 w-4 animate-spin" />
+                          {t("editDialog.resetData.loading")}
+                        </>
+                      ) : (
+                        t("editDialog.resetData.confirm")
+                      )}
+                    </AlertDialogAction>
+                  </AlertDialogFooter>
+                </AlertDialogContent>
+              </AlertDialog>
+            </div>
+          </section>
+
           <DangerZone
           <DangerZone
             userId={user.id}
             userId={user.id}
             userName={user.name}
             userName={user.name}

+ 5 - 7
src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx

@@ -216,7 +216,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
       />
       />
 
 
       <div className="space-y-2">
       <div className="space-y-2">
-        <Label>Cache TTL 覆写</Label>
+        <Label>{t("cacheTtl.label")}</Label>
         <Select
         <Select
           value={form.values.cacheTtlPreference}
           value={form.values.cacheTtlPreference}
           onValueChange={(val) =>
           onValueChange={(val) =>
@@ -227,14 +227,12 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
             <SelectValue placeholder="inherit" />
             <SelectValue placeholder="inherit" />
           </SelectTrigger>
           </SelectTrigger>
           <SelectContent>
           <SelectContent>
-            <SelectItem value="inherit">不覆写(跟随供应商/客户端)</SelectItem>
-            <SelectItem value="5m">5m</SelectItem>
-            <SelectItem value="1h">1h</SelectItem>
+            <SelectItem value="inherit">{t("cacheTtl.options.inherit")}</SelectItem>
+            <SelectItem value="5m">{t("cacheTtl.options.5m")}</SelectItem>
+            <SelectItem value="1h">{t("cacheTtl.options.1h")}</SelectItem>
           </SelectContent>
           </SelectContent>
         </Select>
         </Select>
-        <p className="text-xs text-muted-foreground">
-          强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。
-        </p>
+        <p className="text-xs text-muted-foreground">{t("cacheTtl.description")}</p>
       </div>
       </div>
 
 
       <FormGrid columns={2}>
       <FormGrid columns={2}>

+ 4 - 0
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx

@@ -1,4 +1,5 @@
 "use client";
 "use client";
+import { useQueryClient } from "@tanstack/react-query";
 import { useRouter } from "next/navigation";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useState, useTransition } from "react";
 import { useCallback, useEffect, useState, useTransition } from "react";
@@ -49,6 +50,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
   const [isPending, startTransition] = useTransition();
   const [isPending, startTransition] = useTransition();
   const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const router = useRouter();
+  const queryClient = useQueryClient();
   const t = useTranslations("quota.keys.editKeyForm");
   const t = useTranslations("quota.keys.editKeyForm");
   const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields");
   const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields");
   const tBalancePage = useTranslations(
   const tBalancePage = useTranslations(
@@ -128,6 +130,8 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
             return;
             return;
           }
           }
           toast.success(t("success"));
           toast.success(t("success"));
+          queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] });
+          queryClient.invalidateQueries({ queryKey: ["userTags"] });
           onSuccess?.();
           onSuccess?.();
           router.refresh();
           router.refresh();
         } catch (err) {
         } catch (err) {

+ 1 - 1
src/app/[locale]/dashboard/_components/user/key-list.tsx

@@ -236,7 +236,7 @@ export function KeyList({
           {record.lastUsedAt ? (
           {record.lastUsedAt ? (
             <>
             <>
               <div className="text-sm">
               <div className="text-sm">
-                <RelativeTime date={record.lastUsedAt} />
+                <RelativeTime date={record.lastUsedAt} format="short" />
               </div>
               </div>
               {record.lastProviderName && (
               {record.lastProviderName && (
                 <div className="text-xs text-muted-foreground">
                 <div className="text-xs text-muted-foreground">

+ 35 - 20
src/app/[locale]/dashboard/_components/user/key-row-item.tsx

@@ -1,6 +1,16 @@
 "use client";
 "use client";
 
 
-import { BarChart3, Copy, Eye, FileText, Info, Pencil, Trash2 } from "lucide-react";
+import {
+  Activity,
+  BarChart3,
+  Coins,
+  Copy,
+  Eye,
+  FileText,
+  Info,
+  Pencil,
+  Trash2,
+} from "lucide-react";
 import { useRouter } from "next/navigation";
 import { useRouter } from "next/navigation";
 import { useLocale, useTranslations } from "next-intl";
 import { useLocale, useTranslations } from "next-intl";
 import { useEffect, useState } from "react";
 import { useEffect, useState } from "react";
@@ -25,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
 import { cn } from "@/lib/utils";
 import { cn } from "@/lib/utils";
 import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
 import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
 import { formatDate } from "@/lib/utils/date-format";
 import { formatDate } from "@/lib/utils/date-format";
+import { formatTokenAmount } from "@/lib/utils/token";
 import { type QuickRenewKey, QuickRenewKeyDialog } from "./forms/quick-renew-key-dialog";
 import { type QuickRenewKey, QuickRenewKeyDialog } from "./forms/quick-renew-key-dialog";
 import { KeyFullDisplayDialog } from "./key-full-display-dialog";
 import { KeyFullDisplayDialog } from "./key-full-display-dialog";
 import { KeyQuotaUsageDialog } from "./key-quota-usage-dialog";
 import { KeyQuotaUsageDialog } from "./key-quota-usage-dialog";
@@ -40,6 +51,7 @@ export interface KeyRowItemProps {
     providerGroup?: string | null;
     providerGroup?: string | null;
     todayUsage: number;
     todayUsage: number;
     todayCallCount: number;
     todayCallCount: number;
+    todayTokens: number;
     lastUsedAt: Date | null;
     lastUsedAt: Date | null;
     expiresAt: string;
     expiresAt: string;
     status: "enabled" | "disabled";
     status: "enabled" | "disabled";
@@ -67,9 +79,11 @@ export interface KeyRowItemProps {
       group: string;
       group: string;
       todayUsage: string;
       todayUsage: string;
       todayCost: string;
       todayCost: string;
+      todayTokens: string;
       lastUsed: string;
       lastUsed: string;
       actions: string;
       actions: string;
       callsLabel: string;
       callsLabel: string;
+      tokensLabel: string;
       costLabel: string;
       costLabel: string;
     };
     };
     actions: {
     actions: {
@@ -180,7 +194,6 @@ export function KeyRowItem({
   // 计算 key 过期状态
   // 计算 key 过期状态
   const keyExpiryStatus = getKeyExpiryStatus(localStatus, localExpiresAt);
   const keyExpiryStatus = getKeyExpiryStatus(localStatus, localExpiresAt);
   const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length);
   const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length);
-  const effectiveGroupText = effectiveGroups.join(", ");
 
 
   const canReveal = Boolean(keyData.fullKey);
   const canReveal = Boolean(keyData.fullKey);
   const canCopy = Boolean(keyData.canCopy && keyData.fullKey);
   const canCopy = Boolean(keyData.canCopy && keyData.fullKey);
@@ -302,8 +315,8 @@ export function KeyRowItem({
       className={cn(
       className={cn(
         "grid items-center gap-3 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/40 transition-colors",
         "grid items-center gap-3 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/40 transition-colors",
         isMultiSelectMode
         isMultiSelectMode
-          ? "grid-cols-[24px_2fr_3fr_3fr_1fr_2fr_1.5fr_1.5fr_1.5fr]"
-          : "grid-cols-[2fr_3fr_2.5fr_1fr_2fr_1.5fr_1.5fr_1.5fr]",
+          ? "grid-cols-[24px_2fr_3fr_2.5fr_1.2fr_1.2fr_1.2fr_1.2fr_1.2fr_1.5fr]"
+          : "grid-cols-[2fr_3fr_2.5fr_1.2fr_1.2fr_1.2fr_1.2fr_1.2fr_1.5fr]",
         highlight && "bg-primary/10 ring-1 ring-primary/30"
         highlight && "bg-primary/10 ring-1 ring-primary/30"
       )}
       )}
     >
     >
@@ -398,17 +411,12 @@ export function KeyRowItem({
                         key={group}
                         key={group}
                         variant="outline"
                         variant="outline"
                         className="text-xs font-mono max-w-[120px] truncate"
                         className="text-xs font-mono max-w-[120px] truncate"
-                        title={group}
                       >
                       >
                         {group}
                         {group}
                       </Badge>
                       </Badge>
                     ))}
                     ))}
                     {remainingGroups > 0 ? (
                     {remainingGroups > 0 ? (
-                      <Badge
-                        variant="outline"
-                        className="text-xs font-mono shrink-0"
-                        title={effectiveGroupText}
-                      >
+                      <Badge variant="outline" className="text-xs font-mono shrink-0">
                         +{remainingGroups}
                         +{remainingGroups}
                       </Badge>
                       </Badge>
                     ) : null}
                     ) : null}
@@ -421,9 +429,11 @@ export function KeyRowItem({
               </div>
               </div>
             </TooltipTrigger>
             </TooltipTrigger>
             <TooltipContent side="bottom" align="start" className="max-w-[420px]">
             <TooltipContent side="bottom" align="start" className="max-w-[420px]">
-              <p className="text-xs whitespace-normal break-words font-mono">
-                {effectiveGroupText}
-              </p>
+              <ul className="text-xs space-y-1 font-mono">
+                {effectiveGroups.map((group) => (
+                  <li key={group}>{group}</li>
+                ))}
+              </ul>
             </TooltipContent>
             </TooltipContent>
           </Tooltip>
           </Tooltip>
         </div>
         </div>
@@ -434,23 +444,28 @@ export function KeyRowItem({
         className="text-right tabular-nums flex items-center justify-end gap-1"
         className="text-right tabular-nums flex items-center justify-end gap-1"
         title={translations.fields.todayUsage}
         title={translations.fields.todayUsage}
       >
       >
-        <span className="text-xs text-muted-foreground">{translations.fields.callsLabel}:</span>
+        <Activity className="h-3 w-3 text-muted-foreground" />
         <span>{Number(keyData.todayCallCount || 0).toLocaleString()}</span>
         <span>{Number(keyData.todayCallCount || 0).toLocaleString()}</span>
       </div>
       </div>
 
 
-      {/* 今日消耗(成本) */}
+      {/* 今日Token数 */}
       <div
       <div
-        className="text-right font-mono tabular-nums flex items-center justify-end gap-1"
-        title={translations.fields.todayCost}
+        className="text-right tabular-nums flex items-center justify-end gap-1"
+        title={translations.fields.todayTokens}
       >
       >
-        <span className="text-xs text-muted-foreground">{translations.fields.costLabel}:</span>
-        <span>{formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}</span>
+        <Coins className="h-3 w-3 text-muted-foreground" />
+        <span>{formatTokenAmount(keyData.todayTokens || 0)}</span>
+      </div>
+
+      {/* 今日消耗(成本) */}
+      <div className="text-right font-mono tabular-nums" title={translations.fields.todayCost}>
+        {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}
       </div>
       </div>
 
 
       {/* 最后使用 */}
       {/* 最后使用 */}
       <div className="min-w-0" title={translations.fields.lastUsed}>
       <div className="min-w-0" title={translations.fields.lastUsed}>
         {keyData.lastUsedAt ? (
         {keyData.lastUsedAt ? (
-          <RelativeTime date={keyData.lastUsedAt} autoUpdate={false} />
+          <RelativeTime date={keyData.lastUsedAt} autoUpdate={false} format="short" />
         ) : (
         ) : (
           <span className="text-muted-foreground">-</span>
           <span className="text-muted-foreground">-</span>
         )}
         )}

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

@@ -1,7 +1,16 @@
 "use client";
 "use client";
 
 
 import { useQueryClient } from "@tanstack/react-query";
 import { useQueryClient } from "@tanstack/react-query";
-import { ChevronDown, ChevronRight, Plus, SquarePen } from "lucide-react";
+import {
+  CheckCircle2,
+  ChevronDown,
+  ChevronRight,
+  CircleOff,
+  Clock,
+  Plus,
+  SquarePen,
+  XCircle,
+} from "lucide-react";
 import { useLocale, useTranslations } from "next-intl";
 import { useLocale, useTranslations } from "next-intl";
 import { useEffect, useState, useTransition } from "react";
 import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { toast } from "sonner";
@@ -209,6 +218,8 @@ export function UserKeyTableRow({
         return;
         return;
       }
       }
       toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled"));
       toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled"));
+      // Инвалидировать кэш React Query для всех фильтров
+      queryClient.invalidateQueries({ queryKey: ["users"] });
       // 刷新服务端数据
       // 刷新服务端数据
       router.refresh();
       router.refresh();
     } catch (error) {
     } catch (error) {
@@ -267,30 +278,68 @@ export function UserKeyTableRow({
             <span className="sr-only">
             <span className="sr-only">
               {isExpanded ? translations.collapse : translations.expand}
               {isExpanded ? translations.collapse : translations.expand}
             </span>
             </span>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <span className="shrink-0 cursor-help">
+                  {expiryStatus.label === "active" && (
+                    <CheckCircle2 className="h-4 w-4 text-green-500" />
+                  )}
+                  {expiryStatus.label === "disabled" && (
+                    <CircleOff className="h-4 w-4 text-muted-foreground" />
+                  )}
+                  {expiryStatus.label === "expiringSoon" && (
+                    <Clock className="h-4 w-4 text-yellow-500" />
+                  )}
+                  {expiryStatus.label === "expired" && (
+                    <XCircle className="h-4 w-4 text-destructive" />
+                  )}
+                </span>
+              </TooltipTrigger>
+              <TooltipContent>{tUserStatus(expiryStatus.label)}</TooltipContent>
+            </Tooltip>
             <span className="font-medium truncate">{user.name}</span>
             <span className="font-medium truncate">{user.name}</span>
-            <Badge variant={expiryStatus.variant} className="text-[10px] shrink-0">
-              {tUserStatus(expiryStatus.label)}
-            </Badge>
-            {visibleGroups.map((group) => {
-              const bgColor = getGroupColor(group);
-              return (
-                <Badge
-                  key={group}
-                  className="text-[10px] shrink-0"
-                  style={{
-                    backgroundColor: bgColor,
-                    color: getContrastTextColor(bgColor),
-                  }}
-                >
-                  {group}
-                </Badge>
-              );
-            })}
-            {remainingGroupsCount > 0 && (
-              <Badge variant="secondary" className="text-[10px] shrink-0">
-                +{remainingGroupsCount}
-              </Badge>
-            )}
+            {userGroups.length > 0 ? (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <div className="flex items-center gap-1 cursor-help">
+                    {visibleGroups.map((group) => {
+                      if (group.toLowerCase() === "default") {
+                        return (
+                          <Badge key={group} variant="outline" className="text-[10px] shrink-0">
+                            {group}
+                          </Badge>
+                        );
+                      }
+                      const bgColor = getGroupColor(group);
+                      return (
+                        <Badge
+                          key={group}
+                          className="text-[10px] shrink-0"
+                          style={{
+                            backgroundColor: bgColor,
+                            color: getContrastTextColor(bgColor),
+                          }}
+                        >
+                          {group}
+                        </Badge>
+                      );
+                    })}
+                    {remainingGroupsCount > 0 && (
+                      <Badge variant="secondary" className="text-[10px] shrink-0">
+                        +{remainingGroupsCount}
+                      </Badge>
+                    )}
+                  </div>
+                </TooltipTrigger>
+                <TooltipContent side="bottom" align="start">
+                  <ul className="text-xs space-y-1">
+                    {userGroups.map((group) => (
+                      <li key={group}>{group}</li>
+                    ))}
+                  </ul>
+                </TooltipContent>
+              </Tooltip>
+            ) : null}
             {user.tags && user.tags.length > 0 && (
             {user.tags && user.tags.length > 0 && (
               <span className="text-xs text-muted-foreground truncate">
               <span className="text-xs text-muted-foreground truncate">
                 [{user.tags.join(", ")}]
                 [{user.tags.join(", ")}]
@@ -453,6 +502,7 @@ export function UserKeyTableRow({
                     providerGroup: key.providerGroup,
                     providerGroup: key.providerGroup,
                     todayUsage: key.todayUsage,
                     todayUsage: key.todayUsage,
                     todayCallCount: key.todayCallCount,
                     todayCallCount: key.todayCallCount,
+                    todayTokens: key.todayTokens,
                     lastUsedAt: key.lastUsedAt,
                     lastUsedAt: key.lastUsedAt,
                     expiresAt: key.expiresAt,
                     expiresAt: key.expiresAt,
                     status: key.status,
                     status: key.status,

+ 18 - 1
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -1,7 +1,7 @@
 "use client";
 "use client";
 
 
 import { useQueryClient } from "@tanstack/react-query";
 import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Users } from "lucide-react";
+import { Loader2, RefreshCw, Users } from "lucide-react";
 import { useRouter } from "next/navigation";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -91,6 +91,8 @@ export interface UserManagementTableProps {
       failed: string;
       failed: string;
     };
     };
   };
   };
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
 }
 }
 
 
 const USER_ROW_HEIGHT = 52;
 const USER_ROW_HEIGHT = 52;
@@ -124,6 +126,8 @@ export function UserManagementTable({
   onSelectKey,
   onSelectKey,
   onOpenBatchEdit,
   onOpenBatchEdit,
   translations,
   translations,
+  onRefresh,
+  isRefreshing,
 }: UserManagementTableProps) {
 }: UserManagementTableProps) {
   const router = useRouter();
   const router = useRouter();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -422,6 +426,19 @@ export function UserManagementTable({
             />
             />
           ) : null}
           ) : null}
         </div>
         </div>
+
+        {onRefresh ? (
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            onClick={onRefresh}
+            disabled={isRefreshing}
+            title={tUserMgmt("table.refresh")}
+          >
+            <RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
+          </Button>
+        ) : null}
       </div>
       </div>
 
 
       <div className={cn("border border-border rounded-lg", "overflow-hidden")}>
       <div className={cn("border border-border rounded-lg", "overflow-hidden")}>

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

@@ -419,11 +419,11 @@ export function UsageLogsFilters({
         <div className="space-y-2 lg:col-span-4">
         <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.apiKey")}</Label>
           <Label>{t("logs.filters.apiKey")}</Label>
           <Select
           <Select
-            value={localFilters.keyId?.toString() || ""}
+            value={localFilters.keyId?.toString() || "__all__"}
             onValueChange={(value: string) =>
             onValueChange={(value: string) =>
               setLocalFilters({
               setLocalFilters({
                 ...localFilters,
                 ...localFilters,
-                keyId: value ? parseInt(value, 10) : undefined,
+                keyId: value && value !== "__all__" ? parseInt(value, 10) : undefined,
               })
               })
             }
             }
             disabled={isKeysLoading || (isAdmin && !localFilters.userId && keys.length === 0)}
             disabled={isKeysLoading || (isAdmin && !localFilters.userId && keys.length === 0)}
@@ -440,6 +440,7 @@ export function UsageLogsFilters({
               />
               />
             </SelectTrigger>
             </SelectTrigger>
             <SelectContent>
             <SelectContent>
+              <SelectItem value="__all__">{t("logs.filters.allKeys")}</SelectItem>
               {keys.map((key) => (
               {keys.map((key) => (
                 <SelectItem key={key.id} value={key.id.toString()}>
                 <SelectItem key={key.id} value={key.id.toString()}>
                   {key.name}
                   {key.name}
@@ -602,12 +603,17 @@ export function UsageLogsFilters({
           <Label>{t("logs.filters.statusCode")}</Label>
           <Label>{t("logs.filters.statusCode")}</Label>
           <Select
           <Select
             value={
             value={
-              localFilters.excludeStatusCode200 ? "!200" : localFilters.statusCode?.toString() || ""
+              localFilters.excludeStatusCode200
+                ? "!200"
+                : localFilters.statusCode?.toString() || "__all__"
             }
             }
             onValueChange={(value: string) =>
             onValueChange={(value: string) =>
               setLocalFilters({
               setLocalFilters({
                 ...localFilters,
                 ...localFilters,
-                statusCode: value && value !== "!200" ? parseInt(value, 10) : undefined,
+                statusCode:
+                  value && value !== "!200" && value !== "__all__"
+                    ? parseInt(value, 10)
+                    : undefined,
                 excludeStatusCode200: value === "!200",
                 excludeStatusCode200: value === "!200",
               })
               })
             }
             }
@@ -617,6 +623,7 @@ export function UsageLogsFilters({
               <SelectValue placeholder={t("logs.filters.allStatusCodes")} />
               <SelectValue placeholder={t("logs.filters.allStatusCodes")} />
             </SelectTrigger>
             </SelectTrigger>
             <SelectContent>
             <SelectContent>
+              <SelectItem value="__all__">{t("logs.filters.allStatusCodes")}</SelectItem>
               <SelectItem value="!200">{t("logs.statusCodes.not200")}</SelectItem>
               <SelectItem value="!200">{t("logs.statusCodes.not200")}</SelectItem>
               <SelectItem value="200">{t("logs.statusCodes.200")}</SelectItem>
               <SelectItem value="200">{t("logs.statusCodes.200")}</SelectItem>
               <SelectItem value="400">{t("logs.statusCodes.400")}</SelectItem>
               <SelectItem value="400">{t("logs.statusCodes.400")}</SelectItem>

+ 1 - 1
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -124,7 +124,7 @@ export function UsageLogsTable({
                   >
                   >
                     <TableCell className="font-mono text-xs w-[90px] max-w-[90px] overflow-hidden">
                     <TableCell className="font-mono text-xs w-[90px] max-w-[90px] overflow-hidden">
                       <div className="truncate">
                       <div className="truncate">
-                        <RelativeTime date={log.createdAt} fallback="-" />
+                        <RelativeTime date={log.createdAt} fallback="-" format="short" />
                       </div>
                       </div>
                     </TableCell>
                     </TableCell>
                     <TableCell>{log.userName}</TableCell>
                     <TableCell>{log.userName}</TableCell>

+ 1 - 1
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -270,7 +270,7 @@ export function VirtualizedLogsTable({
                 >
                 >
                   {/* Time */}
                   {/* Time */}
                   <div className="flex-[0.8] min-w-[80px] font-mono text-xs truncate pl-2">
                   <div className="flex-[0.8] min-w-[80px] font-mono text-xs truncate pl-2">
-                    <RelativeTime date={log.createdAt} fallback="-" />
+                    <RelativeTime date={log.createdAt} fallback="-" format="short" />
                   </div>
                   </div>
 
 
                   {/* User */}
                   {/* User */}

+ 2 - 0
src/app/[locale]/dashboard/providers/page.tsx

@@ -1,5 +1,6 @@
 import { BarChart3 } from "lucide-react";
 import { BarChart3 } from "lucide-react";
 import { getTranslations } from "next-intl/server";
 import { getTranslations } from "next-intl/server";
+import { AutoSortPriorityDialog } from "@/app/[locale]/settings/providers/_components/auto-sort-priority-dialog";
 import { ProviderManagerLoader } from "@/app/[locale]/settings/providers/_components/provider-manager-loader";
 import { ProviderManagerLoader } from "@/app/[locale]/settings/providers/_components/provider-manager-loader";
 import { SchedulingRulesDialog } from "@/app/[locale]/settings/providers/_components/scheduling-rules-dialog";
 import { SchedulingRulesDialog } from "@/app/[locale]/settings/providers/_components/scheduling-rules-dialog";
 import { Section } from "@/components/section";
 import { Section } from "@/components/section";
@@ -50,6 +51,7 @@ export default async function DashboardProvidersPage({
                 {t("providers.section.leaderboard")}
                 {t("providers.section.leaderboard")}
               </Link>
               </Link>
             </Button>
             </Button>
+            <AutoSortPriorityDialog />
             <SchedulingRulesDialog />
             <SchedulingRulesDialog />
           </>
           </>
         }
         }

+ 57 - 2
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -7,7 +7,7 @@ import {
   useQuery,
   useQuery,
   useQueryClient,
   useQueryClient,
 } from "@tanstack/react-query";
 } from "@tanstack/react-query";
-import { Loader2, Plus, Search } from "lucide-react";
+import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users";
 import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users";
@@ -67,6 +67,8 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
   const tUserMgmt = useTranslations("dashboard.userManagement");
   const tUserMgmt = useTranslations("dashboard.userManagement");
   const tKeyList = useTranslations("dashboard.keyList");
   const tKeyList = useTranslations("dashboard.keyList");
   const tCommon = useTranslations("common");
   const tCommon = useTranslations("common");
+  const tProviderGroup = useTranslations("myUsage.providerGroup");
+  const tRestrictions = useTranslations("myUsage.accessRestrictions");
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const isAdmin = currentUser.role === "admin";
   const isAdmin = currentUser.role === "admin";
   const [searchTerm, setSearchTerm] = useState("");
   const [searchTerm, setSearchTerm] = useState("");
@@ -138,6 +140,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     isFetching,
     isFetching,
     isError,
     isError,
     error,
     error,
+    refetch,
   } = useInfiniteQuery({
   } = useInfiniteQuery({
     queryKey,
     queryKey,
     queryFn: async ({ pageParam }) => {
     queryFn: async ({ pageParam }) => {
@@ -434,9 +437,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             group: tUserMgmt("table.keyRow.group"),
             group: tUserMgmt("table.keyRow.group"),
             todayUsage: tUserMgmt("table.keyRow.todayUsage"),
             todayUsage: tUserMgmt("table.keyRow.todayUsage"),
             todayCost: tUserMgmt("table.keyRow.todayCost"),
             todayCost: tUserMgmt("table.keyRow.todayCost"),
+            todayTokens: tUserMgmt("table.keyRow.todayTokens"),
             lastUsed: tUserMgmt("table.keyRow.lastUsed"),
             lastUsed: tUserMgmt("table.keyRow.lastUsed"),
             actions: tUserMgmt("table.keyRow.actions"),
             actions: tUserMgmt("table.keyRow.actions"),
             callsLabel: tUserMgmt("table.keyRow.fields.callsLabel"),
             callsLabel: tUserMgmt("table.keyRow.fields.callsLabel"),
+            tokensLabel: tUserMgmt("table.keyRow.fields.tokensLabel"),
             costLabel: tUserMgmt("table.keyRow.fields.costLabel"),
             costLabel: tUserMgmt("table.keyRow.fields.costLabel"),
           },
           },
           actions: {
           actions: {
@@ -504,6 +509,55 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
         )}
         )}
       </div>
       </div>
 
 
+      {/* Provider Group & Access Restrictions block (non-admin users only) */}
+      {!isAdmin && selfUser && (
+        <div className="grid grid-cols-1 gap-4 rounded-lg border bg-muted/40 p-4 sm:grid-cols-2">
+          {/* Provider Groups */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-base font-semibold">
+              <Layers className="h-4 w-4" />
+              <span>{tProviderGroup("title")}</span>
+            </div>
+            <div className="space-y-1">
+              <div className="flex items-baseline gap-1.5">
+                <span className="text-xs text-muted-foreground">
+                  {tProviderGroup("userGroup")}:
+                </span>
+                <span className="text-sm font-semibold text-foreground">
+                  {selfUser.providerGroup || tProviderGroup("allProviders")}
+                </span>
+              </div>
+            </div>
+          </div>
+
+          {/* Access Restrictions */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-base font-semibold">
+              <ShieldCheck className="h-4 w-4" />
+              <span>{tRestrictions("title")}</span>
+            </div>
+            <div className="space-y-1">
+              <div className="flex items-baseline gap-1.5">
+                <span className="text-xs text-muted-foreground">{tRestrictions("models")}:</span>
+                <span className="text-sm font-semibold text-foreground">
+                  {selfUser.allowedModels?.length
+                    ? selfUser.allowedModels.join(", ")
+                    : tRestrictions("noRestrictions")}
+                </span>
+              </div>
+              <div className="flex items-baseline gap-1.5">
+                <span className="text-xs text-muted-foreground">{tRestrictions("clients")}:</span>
+                <span className="text-sm font-semibold text-foreground">
+                  {selfUser.allowedClients?.length
+                    ? selfUser.allowedClients.join(", ")
+                    : tRestrictions("noRestrictions")}
+                </span>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* Toolbar with search and filters */}
       {/* Toolbar with search and filters */}
       <div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center">
       <div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center">
         {/* Search input */}
         {/* Search input */}
@@ -628,7 +682,6 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
         </div>
         </div>
       ) : (
       ) : (
         <div className="space-y-3">
         <div className="space-y-3">
-          <div>{isRefreshing ? <InlineLoading label={tCommon("loading")} /> : null}</div>
           <UserManagementTable
           <UserManagementTable
             users={visibleUsers}
             users={visibleUsers}
             hasNextPage={hasNextPage}
             hasNextPage={hasNextPage}
@@ -651,6 +704,8 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             onSelectKey={handleSelectKey}
             onSelectKey={handleSelectKey}
             onOpenBatchEdit={handleOpenBatchEdit}
             onOpenBatchEdit={handleOpenBatchEdit}
             translations={tableTranslations}
             translations={tableTranslations}
+            onRefresh={() => refetch()}
+            isRefreshing={isRefreshing}
           />
           />
         </div>
         </div>
       )}
       )}

+ 37 - 3
src/app/api/auth/login/route.ts

@@ -1,21 +1,50 @@
 import { type NextRequest, NextResponse } from "next/server";
 import { type NextRequest, NextResponse } from "next/server";
+import { getTranslations } from "next-intl/server";
+import { defaultLocale, type Locale, locales } from "@/i18n/config";
 import { getLoginRedirectTarget, setAuthCookie, validateKey } from "@/lib/auth";
 import { getLoginRedirectTarget, setAuthCookie, validateKey } from "@/lib/auth";
 import { logger } from "@/lib/logger";
 import { logger } from "@/lib/logger";
 
 
 // 需要数据库连接
 // 需要数据库连接
 export const runtime = "nodejs";
 export const runtime = "nodejs";
 
 
+/**
+ * Get locale from request (cookie or Accept-Language header)
+ */
+function getLocaleFromRequest(request: NextRequest): Locale {
+  // 1. Check NEXT_LOCALE cookie
+  const localeCookie = request.cookies.get("NEXT_LOCALE")?.value;
+  if (localeCookie && locales.includes(localeCookie as Locale)) {
+    return localeCookie as Locale;
+  }
+
+  // 2. Check Accept-Language header
+  const acceptLanguage = request.headers.get("accept-language");
+  if (acceptLanguage) {
+    for (const locale of locales) {
+      if (acceptLanguage.toLowerCase().includes(locale.toLowerCase())) {
+        return locale;
+      }
+    }
+  }
+
+  // 3. Fall back to default
+  return defaultLocale;
+}
+
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
+  const locale = getLocaleFromRequest(request);
+
   try {
   try {
+    const t = await getTranslations({ locale, namespace: "auth.errors" });
     const { key } = await request.json();
     const { key } = await request.json();
 
 
     if (!key) {
     if (!key) {
-      return NextResponse.json({ error: "请输入 API Key" }, { status: 400 });
+      return NextResponse.json({ error: t("apiKeyRequired") }, { status: 400 });
     }
     }
 
 
     const session = await validateKey(key, { allowReadOnlyAccess: true });
     const session = await validateKey(key, { allowReadOnlyAccess: true });
     if (!session) {
     if (!session) {
-      return NextResponse.json({ error: "API Key 无效或已过期" }, { status: 401 });
+      return NextResponse.json({ error: t("apiKeyInvalidOrExpired") }, { status: 401 });
     }
     }
 
 
     // 设置认证 cookie
     // 设置认证 cookie
@@ -35,6 +64,11 @@ export async function POST(request: NextRequest) {
     });
     });
   } catch (error) {
   } catch (error) {
     logger.error("Login error:", error);
     logger.error("Login error:", error);
-    return NextResponse.json({ error: "登录失败,请稍后重试" }, { status: 500 });
+    try {
+      const t = await getTranslations({ locale, namespace: "auth.errors" });
+      return NextResponse.json({ error: t("serverError") }, { status: 500 });
+    } catch {
+      return NextResponse.json({ error: "Server error" }, { status: 500 });
+    }
   }
   }
 }
 }

+ 3 - 3
src/components/form/form-layout.tsx

@@ -51,12 +51,12 @@ export function DialogFormLayout({
   const t = useTranslations("forms");
   const t = useTranslations("forms");
   return (
   return (
     <form onSubmit={onSubmit} className="flex flex-col min-h-0 flex-1" noValidate>
     <form onSubmit={onSubmit} className="flex flex-col min-h-0 flex-1" noValidate>
-      <DialogHeader className="flex-shrink-0">
+      <DialogHeader className="flex-shrink-0 px-6 pt-6 pb-4 border-b">
         <DialogTitle>{config.title}</DialogTitle>
         <DialogTitle>{config.title}</DialogTitle>
         {config.description && <DialogDescription>{config.description}</DialogDescription>}
         {config.description && <DialogDescription>{config.description}</DialogDescription>}
       </DialogHeader>
       </DialogHeader>
 
 
-      <div className="flex-1 overflow-y-auto min-h-0 py-4 px-1 -mx-1">
+      <div className="flex-1 overflow-y-auto min-h-0 py-6 px-6">
         <div className="grid gap-4">
         <div className="grid gap-4">
           {children}
           {children}
 
 
@@ -68,7 +68,7 @@ export function DialogFormLayout({
         </div>
         </div>
       </div>
       </div>
 
 
-      <DialogFooter className="flex-shrink-0 pt-4 border-t">
+      <DialogFooter className="flex-shrink-0 px-6 py-4 border-t">
         <DialogClose asChild>
         <DialogClose asChild>
           <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
           <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
             {config.cancelText || t("common.cancel")}
             {config.cancelText || t("common.cancel")}

+ 5 - 3
src/components/section.tsx

@@ -13,12 +13,14 @@ export function Section({ title, description, actions, children, className }: Se
     <section
     <section
       className={`bg-card text-card-foreground border border-border rounded-xl shadow-sm p-5 ${className ?? ""}`}
       className={`bg-card text-card-foreground border border-border rounded-xl shadow-sm p-5 ${className ?? ""}`}
     >
     >
-      <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
-        <div>
+      <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
+        <div className="min-w-0 shrink">
           <h2 className="text-base font-semibold tracking-tight">{title}</h2>
           <h2 className="text-base font-semibold tracking-tight">{title}</h2>
           {description ? <p className="text-sm text-muted-foreground mt-1">{description}</p> : null}
           {description ? <p className="text-sm text-muted-foreground mt-1">{description}</p> : null}
         </div>
         </div>
-        {actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
+        {actions ? (
+          <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div>
+        ) : null}
       </div>
       </div>
       {children}
       {children}
     </section>
     </section>

+ 40 - 4
src/components/ui/relative-time.tsx

@@ -1,8 +1,8 @@
 "use client";
 "use client";
 
 
 import { format as formatDate } from "date-fns";
 import { format as formatDate } from "date-fns";
-import { useLocale } from "next-intl";
-import { useEffect, useMemo, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { formatDateDistance } from "@/lib/utils/date-format";
 import { formatDateDistance } from "@/lib/utils/date-format";
 
 
@@ -12,6 +12,7 @@ interface RelativeTimeProps {
   fallback?: string;
   fallback?: string;
   autoUpdate?: boolean;
   autoUpdate?: boolean;
   updateInterval?: number;
   updateInterval?: number;
+  format?: "full" | "short";
 }
 }
 
 
 /**
 /**
@@ -29,10 +30,40 @@ export function RelativeTime({
   fallback = "—",
   fallback = "—",
   autoUpdate = true,
   autoUpdate = true,
   updateInterval = 10000, // 默认每 10 秒更新
   updateInterval = 10000, // 默认每 10 秒更新
+  format = "full",
 }: RelativeTimeProps) {
 }: RelativeTimeProps) {
   const [timeAgo, setTimeAgo] = useState<string>(fallback);
   const [timeAgo, setTimeAgo] = useState<string>(fallback);
   const [mounted, setMounted] = useState(false);
   const [mounted, setMounted] = useState(false);
   const locale = useLocale();
   const locale = useLocale();
+  const tShort = useTranslations("common.relativeTimeShort");
+
+  // Format short distance with i18n
+  const formatShortDistance = useCallback(
+    (dateObj: Date, now: Date): string => {
+      if (Number.isNaN(dateObj.getTime())) return fallback;
+      if (dateObj > now) return tShort("now");
+
+      const diffMs = now.getTime() - dateObj.getTime();
+      const seconds = Math.floor(diffMs / 1000);
+      const minutes = Math.floor(seconds / 60);
+      const hours = Math.floor(minutes / 60);
+      const days = Math.floor(hours / 24);
+      const weeks = Math.floor(days / 7);
+      const months = Math.floor(days / 30);
+      const years = Math.floor(days / 365);
+
+      if (years > 0) return tShort("yearsAgo", { count: years });
+      if (months > 0) return tShort("monthsAgo", { count: months });
+      if (weeks > 0) return tShort("weeksAgo", { count: weeks });
+      if (days > 0) return tShort("daysAgo", { count: days });
+      if (hours > 0) return tShort("hoursAgo", { count: hours });
+      if (minutes > 0) return tShort("minutesAgo", { count: minutes });
+      if (seconds > 0) return tShort("secondsAgo", { count: seconds });
+
+      return tShort("now");
+    },
+    [tShort, fallback]
+  );
 
 
   // Precompute an absolute timestamp string for tooltip content. Include timezone display.
   // Precompute an absolute timestamp string for tooltip content. Include timezone display.
   const absolute = useMemo(() => {
   const absolute = useMemo(() => {
@@ -56,7 +87,12 @@ export function RelativeTime({
     // 计算相对时间
     // 计算相对时间
     const updateTime = () => {
     const updateTime = () => {
       const dateObj = typeof date === "string" ? new Date(date) : date;
       const dateObj = typeof date === "string" ? new Date(date) : date;
-      setTimeAgo(formatDateDistance(dateObj, new Date(), locale));
+      const now = new Date();
+      if (format === "short") {
+        setTimeAgo(formatShortDistance(dateObj, now));
+      } else {
+        setTimeAgo(formatDateDistance(dateObj, now, locale));
+      }
     };
     };
 
 
     updateTime();
     updateTime();
@@ -67,7 +103,7 @@ export function RelativeTime({
     const interval = setInterval(updateTime, updateInterval);
     const interval = setInterval(updateTime, updateInterval);
 
 
     return () => clearInterval(interval);
     return () => clearInterval(interval);
-  }, [date, autoUpdate, updateInterval, locale]);
+  }, [date, autoUpdate, updateInterval, locale, format, formatShortDistance]);
 
 
   // 服务端渲染和客户端首次渲染显示占位符
   // 服务端渲染和客户端首次渲染显示占位符
   if (!mounted) {
   if (!mounted) {

+ 1 - 0
src/lib/redis/index.ts

@@ -2,4 +2,5 @@ import "server-only";
 
 
 export { closeRedis, getRedisClient } from "./client";
 export { closeRedis, getRedisClient } from "./client";
 export { getLeaderboardWithCache, invalidateLeaderboardCache } from "./leaderboard-cache";
 export { getLeaderboardWithCache, invalidateLeaderboardCache } from "./leaderboard-cache";
+export { scanPattern } from "./scan-helper";
 export { getActiveConcurrentSessions } from "./session-stats";
 export { getActiveConcurrentSessions } from "./session-stats";

+ 32 - 0
src/lib/redis/scan-helper.ts

@@ -0,0 +1,32 @@
+import "server-only";
+
+import type Redis from "ioredis";
+
+/**
+ * Scan Redis keys by pattern using cursor-based iteration.
+ * Non-blocking alternative to KEYS command.
+ *
+ * @param redis - Redis client instance
+ * @param pattern - Pattern to match (e.g., "key:*:cost_*")
+ * @param count - Number of keys to scan per iteration (default: 100)
+ * @returns Array of matching keys
+ *
+ * @example
+ * const keys = await scanPattern(redis, "session:*:info", 100);
+ */
+export async function scanPattern(redis: Redis, pattern: string, count = 100): Promise<string[]> {
+  const keys: string[] = [];
+  let cursor = "0";
+
+  do {
+    const [nextCursor, batch] = (await redis.scan(cursor, "MATCH", pattern, "COUNT", count)) as [
+      string,
+      string[],
+    ];
+
+    cursor = nextCursor;
+    keys.push(...batch);
+  } while (cursor !== "0");
+
+  return keys;
+}

+ 12 - 2
src/repository/key.ts

@@ -319,7 +319,7 @@ export async function findKeyUsageToday(
  */
  */
 export async function findKeyUsageTodayBatch(
 export async function findKeyUsageTodayBatch(
   userIds: number[]
   userIds: number[]
-): Promise<Map<number, Array<{ keyId: number; totalCost: number }>>> {
+): Promise<Map<number, Array<{ keyId: number; totalCost: number; totalTokens: number }>>> {
   if (userIds.length === 0) {
   if (userIds.length === 0) {
     return new Map();
     return new Map();
   }
   }
@@ -334,6 +334,12 @@ export async function findKeyUsageTodayBatch(
       userId: keys.userId,
       userId: keys.userId,
       keyId: keys.id,
       keyId: keys.id,
       totalCost: sum(messageRequest.costUsd),
       totalCost: sum(messageRequest.costUsd),
+      totalTokens: sql<number>`COALESCE(SUM(
+        COALESCE(${messageRequest.inputTokens}, 0) +
+        COALESCE(${messageRequest.outputTokens}, 0) +
+        COALESCE(${messageRequest.cacheCreationInputTokens}, 0) +
+        COALESCE(${messageRequest.cacheReadInputTokens}, 0)
+      ), 0)::int`,
     })
     })
     .from(keys)
     .from(keys)
     .leftJoin(
     .leftJoin(
@@ -349,7 +355,10 @@ export async function findKeyUsageTodayBatch(
     .where(and(inArray(keys.userId, userIds), isNull(keys.deletedAt)))
     .where(and(inArray(keys.userId, userIds), isNull(keys.deletedAt)))
     .groupBy(keys.userId, keys.id);
     .groupBy(keys.userId, keys.id);
 
 
-  const usageMap = new Map<number, Array<{ keyId: number; totalCost: number }>>();
+  const usageMap = new Map<
+    number,
+    Array<{ keyId: number; totalCost: number; totalTokens: number }>
+  >();
   for (const userId of userIds) {
   for (const userId of userIds) {
     usageMap.set(userId, []);
     usageMap.set(userId, []);
   }
   }
@@ -363,6 +372,7 @@ export async function findKeyUsageTodayBatch(
           const costDecimal = toCostDecimal(row.totalCost) ?? new Decimal(0);
           const costDecimal = toCostDecimal(row.totalCost) ?? new Decimal(0);
           return costDecimal.toDecimalPlaces(6).toNumber();
           return costDecimal.toDecimalPlaces(6).toNumber();
         })(),
         })(),
+        totalTokens: Number(row.totalTokens) || 0,
       });
       });
     }
     }
   }
   }

+ 1 - 1
src/repository/user.ts

@@ -211,7 +211,7 @@ export async function findUserListBatch(
   }
   }
 
 
   if (tagFilterCondition && keyGroupFilterCondition) {
   if (tagFilterCondition && keyGroupFilterCondition) {
-    conditions.push(sql`(${tagFilterCondition} OR ${keyGroupFilterCondition})`);
+    conditions.push(sql`(${tagFilterCondition}) AND (${keyGroupFilterCondition})`);
   } else if (tagFilterCondition) {
   } else if (tagFilterCondition) {
     conditions.push(tagFilterCondition);
     conditions.push(tagFilterCondition);
   } else if (keyGroupFilterCondition) {
   } else if (keyGroupFilterCondition) {

+ 3 - 0
src/types/user.ts

@@ -100,6 +100,7 @@ export interface UserKeyDisplay {
   status: "enabled" | "disabled";
   status: "enabled" | "disabled";
   todayUsage: number; // 今日消耗金额(美元)
   todayUsage: number; // 今日消耗金额(美元)
   todayCallCount: number; // 今日调用次数
   todayCallCount: number; // 今日调用次数
+  todayTokens: number; // 今日消耗Token数
   lastUsedAt: Date | null; // 最后使用时间
   lastUsedAt: Date | null; // 最后使用时间
   lastProviderName: string | null; // 最后调用的供应商名称
   lastProviderName: string | null; // 最后调用的供应商名称
   modelStats: Array<{
   modelStats: Array<{
@@ -167,6 +168,8 @@ export interface KeyDialogUserContext {
   limitMonthlyUsd?: number;
   limitMonthlyUsd?: number;
   limitTotalUsd?: number | null;
   limitTotalUsd?: number | null;
   limitConcurrentSessions?: number;
   limitConcurrentSessions?: number;
+  allowedClients?: string[];
+  allowedModels?: string[];
 }
 }
 
 
 /**
 /**

+ 246 - 0
tests/unit/actions/users-reset-all-statistics.test.ts

@@ -0,0 +1,246 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ERROR_CODES } from "@/lib/utils/error-messages";
+
+// Mock getSession
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+// Mock next-intl
+const getTranslationsMock = vi.fn(async () => (key: string) => key);
+vi.mock("next-intl/server", () => ({
+  getTranslations: getTranslationsMock,
+  getLocale: vi.fn(async () => "en"),
+}));
+
+// Mock next/cache
+const revalidatePathMock = vi.fn();
+vi.mock("next/cache", () => ({
+  revalidatePath: revalidatePathMock,
+}));
+
+// Mock repository/user
+const findUserByIdMock = vi.fn();
+vi.mock("@/repository/user", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/repository/user")>();
+  return {
+    ...actual,
+    findUserById: findUserByIdMock,
+  };
+});
+
+// Mock repository/key
+const findKeyListMock = vi.fn();
+vi.mock("@/repository/key", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/repository/key")>();
+  return {
+    ...actual,
+    findKeyList: findKeyListMock,
+  };
+});
+
+// Mock drizzle db
+const dbDeleteWhereMock = vi.fn();
+const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock }));
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    delete: dbDeleteMock,
+  },
+}));
+
+// Mock logger
+const loggerMock = {
+  info: vi.fn(),
+  warn: vi.fn(),
+  error: vi.fn(),
+};
+vi.mock("@/lib/logger", () => ({
+  logger: loggerMock,
+}));
+
+// Mock Redis
+const redisPipelineMock = {
+  del: vi.fn().mockReturnThis(),
+  exec: vi.fn(),
+};
+const redisMock = {
+  status: "ready",
+  pipeline: vi.fn(() => redisPipelineMock),
+};
+const getRedisClientMock = vi.fn(() => redisMock);
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: getRedisClientMock,
+}));
+
+// Mock scanPattern
+const scanPatternMock = vi.fn();
+vi.mock("@/lib/redis/scan-helper", () => ({
+  scanPattern: scanPatternMock,
+}));
+
+describe("resetUserAllStatistics", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Reset redis mock to ready state
+    redisMock.status = "ready";
+    redisPipelineMock.exec.mockResolvedValue([]);
+    // DB delete returns resolved promise
+    dbDeleteWhereMock.mockResolvedValue(undefined);
+  });
+
+  test("should return PERMISSION_DENIED for non-admin user", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(false);
+    expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
+    expect(findUserByIdMock).not.toHaveBeenCalled();
+  });
+
+  test("should return PERMISSION_DENIED when no session", async () => {
+    getSessionMock.mockResolvedValue(null);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(false);
+    expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED);
+  });
+
+  test("should return NOT_FOUND for non-existent user", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue(null);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(999);
+
+    expect(result.ok).toBe(false);
+    expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND);
+    expect(dbDeleteMock).not.toHaveBeenCalled();
+  });
+
+  test("should successfully reset all user statistics", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([{ id: 1 }, { id: 2 }]);
+    scanPatternMock.mockResolvedValue(["key:1:cost_daily", "key:2:cost_weekly"]);
+    redisPipelineMock.exec.mockResolvedValue([]);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(true);
+    // DB delete called
+    expect(dbDeleteMock).toHaveBeenCalled();
+    expect(dbDeleteWhereMock).toHaveBeenCalled();
+    // Redis operations
+    expect(redisMock.pipeline).toHaveBeenCalled();
+    expect(redisPipelineMock.del).toHaveBeenCalled();
+    expect(redisPipelineMock.exec).toHaveBeenCalled();
+    // Revalidate path
+    expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users");
+    // Logging
+    expect(loggerMock.info).toHaveBeenCalled();
+  });
+
+  test("should succeed even when Redis is not ready", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([{ id: 1 }]);
+    redisMock.status = "connecting";
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(true);
+    // DB delete still called
+    expect(dbDeleteMock).toHaveBeenCalled();
+    // Redis pipeline NOT called (status not ready)
+    expect(redisMock.pipeline).not.toHaveBeenCalled();
+  });
+
+  test("should succeed with warning when Redis has partial failures", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([{ id: 1 }]);
+    scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
+    // Simulate partial failure - some commands return errors
+    redisPipelineMock.exec.mockResolvedValue([
+      [null, 1], // success
+      [new Error("Connection reset"), null], // failure
+    ]);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(true);
+    expect(loggerMock.warn).toHaveBeenCalledWith(
+      "Some Redis deletes failed during user statistics reset",
+      expect.objectContaining({ errorCount: 1, userId: 123 })
+    );
+  });
+
+  test("should succeed with warning when scanPattern fails", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([{ id: 1 }]);
+    // scanPattern fails but is caught by .catch() in Promise.all
+    scanPatternMock.mockRejectedValue(new Error("Redis connection lost"));
+    redisPipelineMock.exec.mockResolvedValue([]);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    // Should still succeed - error is caught inside Promise.all
+    expect(result.ok).toBe(true);
+    expect(loggerMock.warn).toHaveBeenCalled();
+  });
+
+  test("should succeed with error log when pipeline.exec throws", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([{ id: 1 }]);
+    scanPatternMock.mockResolvedValue(["key:1:cost_daily"]);
+    // pipeline.exec throws - caught by outer try-catch
+    redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed"));
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    // Should still succeed - DB logs already deleted
+    expect(result.ok).toBe(true);
+    expect(loggerMock.error).toHaveBeenCalledWith(
+      "Failed to clear Redis cache during user statistics reset",
+      expect.objectContaining({ userId: 123 })
+    );
+  });
+
+  test("should return OPERATION_FAILED on unexpected error", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockRejectedValue(new Error("Database connection failed"));
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(false);
+    expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED);
+    expect(loggerMock.error).toHaveBeenCalled();
+  });
+
+  test("should handle user with no keys", async () => {
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" });
+    findKeyListMock.mockResolvedValue([]); // No keys
+    scanPatternMock.mockResolvedValue([]);
+    redisPipelineMock.exec.mockResolvedValue([]);
+
+    const { resetUserAllStatistics } = await import("@/actions/users");
+    const result = await resetUserAllStatistics(123);
+
+    expect(result.ok).toBe(true);
+    expect(dbDeleteMock).toHaveBeenCalled();
+  });
+});

+ 39 - 0
tests/unit/lib/redis/scan-helper.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from "vitest";
+import type Redis from "ioredis";
+import { scanPattern } from "@/lib/redis/scan-helper";
+
+describe("scanPattern", () => {
+  it("should collect all keys from multiple scan iterations", async () => {
+    const mockRedis = {
+      scan: vi
+        .fn()
+        .mockResolvedValueOnce(["5", ["key:1", "key:2"]])
+        .mockResolvedValueOnce(["0", ["key:3"]]),
+    } as unknown as Redis;
+
+    const result = await scanPattern(mockRedis, "key:*");
+
+    expect(result).toEqual(["key:1", "key:2", "key:3"]);
+    expect(mockRedis.scan).toHaveBeenCalledTimes(2);
+  });
+
+  it("should handle empty result", async () => {
+    const mockRedis = {
+      scan: vi.fn().mockResolvedValueOnce(["0", []]),
+    } as unknown as Redis;
+
+    const result = await scanPattern(mockRedis, "nonexistent:*");
+
+    expect(result).toEqual([]);
+  });
+
+  it("should use custom count parameter", async () => {
+    const mockRedis = {
+      scan: vi.fn().mockResolvedValueOnce(["0", ["key:1"]]),
+    } as unknown as Redis;
+
+    await scanPattern(mockRedis, "key:*", 500);
+
+    expect(mockRedis.scan).toHaveBeenCalledWith("0", "MATCH", "key:*", "COUNT", 500);
+  });
+});