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

fix(ui): dashboard UX improvements and i18n fixes (#650)

* fix(my-usage): UI improvements for My Usage page

- Remove bold font from model name in usage logs table
- Rename "Quota Usage" to "Quota User Usage"
- Rename "Statistics Summary" to "Key Statistics"

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

* fix(i18n): replace hardcoded zh-CN locale with dynamic locale in dashboard charts

- Use useLocale() from next-intl to get current locale
- Replace hardcoded "zh-CN" in toLocaleTimeString/toLocaleDateString/toLocaleString
- Move chartConfig inside component to use translated label
- Add locale to useMemo dependencies where needed

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

* fix(i18n): translate database connection unavailable error message

- Add connectionUnavailable key to all 5 language files
- Use translated message in database-status.tsx when isAvailable is false
- Handle both HTTP 503 errors and status.error from API response

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

* fix(provider-test): use configured proxy for model testing

Previously, the provider model test ignored the configured proxy URL and
always made direct connections. Now the test respects proxyUrl setting
by creating a dispatcher via createProxyAgentForProvider.

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

* feat(i18n): add batchEdit translations and update group field description

- Add providersBatchEdit import to all 5 locale index.ts files
- Update provider group field description to mention select/create behavior

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

* fix(ui): improve MCP passthrough select and raw response display

- Fix MCP passthrough select to show translated label instead of value
- Fix raw response overflow with break-all and overflow-x-hidden

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

* fix(ui): adjust logs table column widths for better user visibility

Shrink Time column (flex 0.8→0.6, min-width 80→56px) and expand User column (flex 0.6→0.8) to show more of the username.

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

* feat(ui): add color indicators for cache hit rate in leaderboard

Apply color coding to cache hit rate column: green (>=85%), yellow (60-84%), orange (<60%)

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

* feat(ui): enhance user key statistics modal with token details

- Add token statistics (input, output, cache creation, cache read) to SQL queries
- Redesign key stats dialog with summary cards and compact model rows
- Add cache hit rate indicator per model with color coding
- Fix user limit refresh by clearing usage cache on refresh button click
- Add i18n translations for new modal fields (5 languages)

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

* fix(ui): improve group tooltip formatting in dashboard users

Add header and bullet list styling to group tooltips for consistency
with request-filters tooltip design.

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

* feat(ui): add expiry countdown badge for user mode in dashboard

- Show compact badge with clock icon and days remaining when <= 7 days
- Display tooltip with full localized text on hover
- Rename column header from "Edit" to "Status" in user mode
- Add i18n support for all 5 languages (en, zh-CN, zh-TW, ja, ru)

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

* perf(ui): memoize chartConfig and daysLeft calculations

- Wrap chartConfig in useMemo to prevent recreation on every render
- Wrap daysLeft calculation in useMemo with localExpiresAt dependency

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

* fix: address PR review feedback

- Move proxy creation into try/catch block (test-service.ts)
- Check 503 status before parsing JSON (database-status.tsx)
- Fix English grammar "User Quota Usage" (myUsage.json)
- Return null for expired keys in getDaysLeft (user-key-table-row.tsx)

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 2 недель назад
Родитель
Сommit
bf2ada282e
44 измененных файлов с 475 добавлено и 118 удалено
  1. 1 0
      messages/en/common.json
  2. 17 1
      messages/en/dashboard.json
  3. 2 2
      messages/en/myUsage.json
  4. 1 0
      messages/en/settings/data.json
  5. 1 1
      messages/en/settings/providers/form/sections.json
  6. 1 0
      messages/ja/common.json
  7. 17 1
      messages/ja/dashboard.json
  8. 2 2
      messages/ja/myUsage.json
  9. 1 0
      messages/ja/settings/data.json
  10. 1 1
      messages/ja/settings/providers/form/sections.json
  11. 1 0
      messages/ru/common.json
  12. 17 1
      messages/ru/dashboard.json
  13. 2 2
      messages/ru/myUsage.json
  14. 1 0
      messages/ru/settings/data.json
  15. 2 2
      messages/ru/settings/providers/form/sections.json
  16. 1 0
      messages/zh-CN/common.json
  17. 17 1
      messages/zh-CN/dashboard.json
  18. 2 2
      messages/zh-CN/myUsage.json
  19. 1 0
      messages/zh-CN/settings/data.json
  20. 2 2
      messages/zh-CN/settings/providers/form/sections.json
  21. 1 0
      messages/zh-TW/common.json
  22. 17 1
      messages/zh-TW/dashboard.json
  23. 2 2
      messages/zh-TW/myUsage.json
  24. 1 0
      messages/zh-TW/settings/data.json
  25. 2 2
      messages/zh-TW/settings/providers/form/sections.json
  26. 6 5
      src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx
  27. 15 10
      src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx
  28. 4 3
      src/app/[locale]/dashboard/_components/rate-limit-top-users.tsx
  29. 6 5
      src/app/[locale]/dashboard/_components/statistics/chart.tsx
  30. 12 5
      src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  31. 169 43
      src/app/[locale]/dashboard/_components/user/key-stats-dialog.tsx
  32. 44 7
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  33. 8 0
      src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx
  34. 6 2
      src/app/[locale]/dashboard/_components/user/user-management-table.tsx
  35. 10 2
      src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
  36. 4 4
      src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
  37. 6 1
      src/app/[locale]/dashboard/users/users-page-client.tsx
  38. 1 1
      src/app/[locale]/my-usage/_components/usage-logs-table.tsx
  39. 5 1
      src/app/[locale]/settings/data/_components/database-status.tsx
  40. 3 1
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx
  41. 2 2
      src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx
  42. 28 2
      src/lib/provider-testing/test-service.ts
  43. 29 1
      src/repository/key.ts
  44. 4 0
      src/types/user.ts

+ 1 - 0
messages/en/common.json

@@ -4,6 +4,7 @@
   "delete": "Delete",
   "confirm": "Confirm",
   "edit": "Edit",
+  "status": "Status",
   "create": "Create",
   "close": "Close",
   "back": "Back",

+ 17 - 1
messages/en/dashboard.json

@@ -1303,8 +1303,23 @@
       "columns": {
         "model": "Model",
         "calls": "Calls",
+        "tokens": "Tokens",
         "cost": "Cost"
       },
+      "modal": {
+        "requests": "Requests",
+        "totalTokens": "Total Tokens",
+        "cost": "Cost",
+        "inputTokens": "Input Tokens",
+        "outputTokens": "Output Tokens",
+        "cacheWrite": "Cache Write",
+        "cacheRead": "Cache Read",
+        "cacheHitRate": "Cache Hit Rate",
+        "cacheTokens": "Cache Tokens",
+        "performanceHigh": "High",
+        "performanceMedium": "Medium",
+        "performanceLow": "Low"
+      },
       "noData": "No usage records today",
       "totalCalls": "Total Calls",
       "totalCost": "Total Cost"
@@ -1655,7 +1670,8 @@
       "clickToEnableUser": "Click to enable user",
       "operationFailed": "Operation failed",
       "deleteFailed": "Delete failed",
-      "deleteSuccess": "Delete successful"
+      "deleteSuccess": "Delete successful",
+      "daysLeft": "{days, plural, =0 {Expires today} =1 {1 day left} other {# days left}}"
     },
     "userEditSection": {
       "sections": {

+ 2 - 2
messages/en/myUsage.json

@@ -78,7 +78,7 @@
     "inheritedFromUser": "Inherited from User"
   },
   "stats": {
-    "title": "Statistics Summary",
+    "title": "Key Statistics",
     "autoRefresh": "Auto refresh every {seconds}s",
     "totalRequests": "Total Requests",
     "totalCost": "Total Cost",
@@ -116,7 +116,7 @@
     "noRestrictions": "No restrictions"
   },
   "quotaCollapsible": {
-    "title": "Quota Usage",
+    "title": "User Quota Usage",
     "daily": "Daily",
     "monthly": "Monthly",
     "total": "Total"

+ 1 - 0
messages/en/settings/data.json

@@ -123,6 +123,7 @@
   },
   "status": {
     "connected": "Database connected",
+    "connectionUnavailable": "Database connection unavailable, please check database service status",
     "error": "Failed to get database status",
     "loading": "Loading...",
     "retry": "Retry",

+ 1 - 1
messages/en/settings/providers/form/sections.json

@@ -284,7 +284,7 @@
         "placeholder": "1.0"
       },
       "group": {
-        "desc": "Group tag. Only users whose providerGroup matches can use this provider. Example: set to \"premium\" to serve users with providerGroup=\"premium\" only",
+        "desc": "Group tag. Select from list or type a new name and press Enter to create (max 50 chars). Only users whose providerGroup matches can use this provider.",
         "label": "Provider Group",
         "placeholder": "e.g. premium, economy"
       },

+ 1 - 0
messages/ja/common.json

@@ -4,6 +4,7 @@
   "delete": "削除",
   "confirm": "確認",
   "edit": "編集",
+  "status": "ステータス",
   "create": "作成",
   "close": "閉じる",
   "back": "戻る",

+ 17 - 1
messages/ja/dashboard.json

@@ -1349,8 +1349,23 @@
       "columns": {
         "model": "モデル",
         "calls": "呼び出し回数",
+        "tokens": "トークン",
         "cost": "消費金額"
       },
+      "modal": {
+        "requests": "リクエスト",
+        "totalTokens": "トークン合計",
+        "cost": "コスト",
+        "inputTokens": "入力トークン",
+        "outputTokens": "出力トークン",
+        "cacheWrite": "キャッシュ書込",
+        "cacheRead": "キャッシュ読取",
+        "cacheHitRate": "キャッシュヒット率",
+        "cacheTokens": "キャッシュトークン",
+        "performanceHigh": "高",
+        "performanceMedium": "中",
+        "performanceLow": "低"
+      },
       "noData": "本日の使用記録はありません",
       "totalCalls": "総呼び出し数",
       "totalCost": "総消費"
@@ -1655,7 +1670,8 @@
       "clickToEnableUser": "クリックしてユーザーを有効化",
       "operationFailed": "操作に失敗しました",
       "deleteFailed": "削除に失敗しました",
-      "deleteSuccess": "削除しました"
+      "deleteSuccess": "削除しました",
+      "daysLeft": "{days, plural, =0 {本日期限} =1 {残り1日} other {残り#日}}"
     },
     "userEditSection": {
       "sections": {

+ 2 - 2
messages/ja/myUsage.json

@@ -78,7 +78,7 @@
     "inheritedFromUser": "ユーザーから継承"
   },
   "stats": {
-    "title": "統計サマリー",
+    "title": "キー統計",
     "autoRefresh": "{seconds}秒ごとに自動更新",
     "totalRequests": "リクエスト総数",
     "totalCost": "総コスト",
@@ -116,7 +116,7 @@
     "noRestrictions": "制限なし"
   },
   "quotaCollapsible": {
-    "title": "クォータ使用状況",
+    "title": "ユーザークォータ使用状況",
     "daily": "日次",
     "monthly": "月次",
     "total": "合計"

+ 1 - 0
messages/ja/settings/data.json

@@ -123,6 +123,7 @@
   },
   "status": {
     "connected": "データベース接続正常",
+    "connectionUnavailable": "データベース接続が利用できません。データベースサービスの状態を確認してください",
     "error": "データベースステータスの取得に失敗しました",
     "loading": "読み込み中...",
     "retry": "再試行",

+ 1 - 1
messages/ja/settings/providers/form/sections.json

@@ -285,7 +285,7 @@
         "placeholder": "1.0"
       },
       "group": {
-        "desc": "グループタグ。ユーザーの providerGroup が一致する場合のみ利用可能。例: \"premium\" に設定すると providerGroup=\"premium\" のユーザーのみ対象",
+        "desc": "グループタグ。リストから選択するか、新しい名前を入力して Enter で作成(最大50文字)。providerGroup が一致するユーザーのみがこのプロバイダーを使用できます。",
         "label": "プロバイダーグループ",
         "placeholder": "例: premium, economy"
       },

+ 1 - 0
messages/ru/common.json

@@ -4,6 +4,7 @@
   "delete": "Удалить",
   "confirm": "Подтвердить",
   "edit": "Редактировать",
+  "status": "Статус",
   "create": "Создать",
   "close": "Закрыть",
   "back": "Назад",

+ 17 - 1
messages/ru/dashboard.json

@@ -1293,8 +1293,23 @@
       "columns": {
         "model": "Модель",
         "calls": "Вызовы",
+        "tokens": "Токены",
         "cost": "Стоимость"
       },
+      "modal": {
+        "requests": "Запросов",
+        "totalTokens": "Всего токенов",
+        "cost": "Стоимость",
+        "inputTokens": "Входные токены",
+        "outputTokens": "Выходные токены",
+        "cacheWrite": "Запись кэша",
+        "cacheRead": "Чтение кэша",
+        "cacheHitRate": "Попадание кэша",
+        "cacheTokens": "Токены кэша",
+        "performanceHigh": "Высокий",
+        "performanceMedium": "Средний",
+        "performanceLow": "Низкий"
+      },
       "noData": "Нет записей использования за сегодня",
       "totalCalls": "Всего вызовов",
       "totalCost": "Общий расход"
@@ -1645,7 +1660,8 @@
       "clickToEnableUser": "Нажмите, чтобы включить пользователя",
       "operationFailed": "Операция не удалась",
       "deleteFailed": "Не удалось удалить",
-      "deleteSuccess": "Удаление успешно"
+      "deleteSuccess": "Удаление успешно",
+      "daysLeft": "{days, plural, =0 {Истекает сегодня} =1 {Остался 1 день} few {Осталось # дня} many {Осталось # дней} other {Осталось # дней}}"
     },
     "userEditSection": {
       "sections": {

+ 2 - 2
messages/ru/myUsage.json

@@ -78,7 +78,7 @@
     "inheritedFromUser": "Наследовано от пользователя"
   },
   "stats": {
-    "title": "Сводка статистики",
+    "title": "Статистика ключа",
     "autoRefresh": "Автообновление каждые {seconds}с",
     "totalRequests": "Всего запросов",
     "totalCost": "Общая стоимость",
@@ -116,7 +116,7 @@
     "noRestrictions": "Без ограничений"
   },
   "quotaCollapsible": {
-    "title": "Использование квоты",
+    "title": "Квота пользователя",
     "daily": "День",
     "monthly": "Месяц",
     "total": "Всего"

+ 1 - 0
messages/ru/settings/data.json

@@ -123,6 +123,7 @@
   },
   "status": {
     "connected": "База данных подключена",
+    "connectionUnavailable": "Подключение к базе данных недоступно, проверьте состояние сервиса базы данных",
     "error": "Не удалось получить статус базы данных",
     "loading": "Загрузка...",
     "retry": "Повторить",

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

@@ -285,9 +285,9 @@
         "placeholder": "1.0"
       },
       "group": {
-        "desc": "Метка группы. Пользователь может использовать провайдера только если его providerGroup совпадает. Пример: значение \"premium\" — только для пользователей с providerGroup=\"premium\"",
+        "desc": "Тег группы. Выберите из списка или введите новое имя и нажмите Enter для создания (макс. 50 символов). Только пользователи с соответствующим providerGroup могут использовать этого провайдера.",
         "label": "Группа провайдера",
-        "placeholder": "например: premium, economy"
+        "placeholder": "напр. premium, economy"
       },
       "priority": {
         "desc": "Меньше — выше приоритет (0 — наивысший). Система выбирает только из провайдеров с максимальным приоритетом. Рекомендации: основной=0, резерв=1, аварийный=2",

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

@@ -4,6 +4,7 @@
   "delete": "删除",
   "confirm": "确认",
   "edit": "编辑",
+  "status": "状态",
   "create": "创建",
   "close": "关闭",
   "back": "返回",

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

@@ -1304,8 +1304,23 @@
       "columns": {
         "model": "模型",
         "calls": "调用次数",
+        "tokens": "Token数",
         "cost": "消费金额"
       },
+      "modal": {
+        "requests": "请求",
+        "totalTokens": "总Token",
+        "cost": "费用",
+        "inputTokens": "输入Token",
+        "outputTokens": "输出Token",
+        "cacheWrite": "缓存写入",
+        "cacheRead": "缓存读取",
+        "cacheHitRate": "缓存命中率",
+        "cacheTokens": "缓存Token",
+        "performanceHigh": "高",
+        "performanceMedium": "中",
+        "performanceLow": "低"
+      },
       "noData": "今日暂无使用记录",
       "totalCalls": "总调用",
       "totalCost": "总消费"
@@ -1614,7 +1629,8 @@
       "clickToEnableUser": "点击启用用户",
       "operationFailed": "操作失败",
       "deleteFailed": "删除失败",
-      "deleteSuccess": "删除成功"
+      "deleteSuccess": "删除成功",
+      "daysLeft": "{days, plural, =0 {今天到期} =1 {剩余1天} other {剩余#天}}"
     },
     "userEditSection": {
       "sections": {

+ 2 - 2
messages/zh-CN/myUsage.json

@@ -78,7 +78,7 @@
     "inheritedFromUser": "继承自用户"
   },
   "stats": {
-    "title": "统计摘要",
+    "title": "密钥统计",
     "autoRefresh": "每{seconds}秒自动刷新",
     "totalRequests": "总请求数",
     "totalCost": "总费用",
@@ -116,7 +116,7 @@
     "noRestrictions": "无限制"
   },
   "quotaCollapsible": {
-    "title": "配额使用",
+    "title": "用户配额使用",
     "daily": "日",
     "monthly": "月",
     "total": "总计"

+ 1 - 0
messages/zh-CN/settings/data.json

@@ -4,6 +4,7 @@
   "status": {
     "loading": "加载中...",
     "error": "获取数据库状态失败",
+    "connectionUnavailable": "数据库连接不可用,请检查数据库服务状态",
     "retry": "重试",
     "connected": "数据库连接正常",
     "unavailable": "数据库不可用",

+ 2 - 2
messages/zh-CN/settings/providers/form/sections.json

@@ -69,8 +69,8 @@
       },
       "group": {
         "label": "供应商分组",
-        "placeholder": "输入分组标签",
-        "desc": "供应商分组标签(支持多个,逗号分隔)。只有用户的 providerGroup 与此值匹配时,该用户才能使用此供应商。留空=对所有用户开放"
+        "placeholder": "例如 premium, economy",
+        "desc": "分组标签。从列表选择或输入新名称后按 Enter 创建(最多50字符)。只有 providerGroup 匹配的用户才能使用此供应商。"
       }
     },
     "cacheTtl": {

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

@@ -4,6 +4,7 @@
   "delete": "刪除",
   "confirm": "確認",
   "edit": "編輯",
+  "status": "狀態",
   "create": "建立",
   "close": "關閉",
   "back": "返回",

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

@@ -1295,8 +1295,23 @@
       "columns": {
         "model": "Model",
         "calls": "呼叫次數",
+        "tokens": "Token",
         "cost": "消費金額"
       },
+      "modal": {
+        "requests": "請求",
+        "totalTokens": "總Token",
+        "cost": "費用",
+        "inputTokens": "輸入Token",
+        "outputTokens": "輸出Token",
+        "cacheWrite": "快取寫入",
+        "cacheRead": "快取讀取",
+        "cacheHitRate": "快取命中率",
+        "cacheTokens": "快取Token",
+        "performanceHigh": "高",
+        "performanceMedium": "中",
+        "performanceLow": "低"
+      },
       "noData": "今日暫無使用記錄",
       "totalCalls": "今日總呼叫",
       "totalCost": "今日總消費"
@@ -1605,7 +1620,8 @@
       "clickToEnableUser": "點擊啟用使用者",
       "operationFailed": "操作失敗",
       "deleteFailed": "刪除失敗",
-      "deleteSuccess": "刪除成功"
+      "deleteSuccess": "刪除成功",
+      "daysLeft": "{days, plural, =0 {今天到期} =1 {剩餘1天} other {剩餘#天}}"
     },
     "userEditSection": {
       "sections": {

+ 2 - 2
messages/zh-TW/myUsage.json

@@ -78,7 +78,7 @@
     "inheritedFromUser": "繼承自使用者"
   },
   "stats": {
-    "title": "統計摘要",
+    "title": "金鑰統計",
     "autoRefresh": "每{seconds}秒自動刷新",
     "totalRequests": "總請求數",
     "totalCost": "總費用",
@@ -116,7 +116,7 @@
     "noRestrictions": "無限制"
   },
   "quotaCollapsible": {
-    "title": "配額使用",
+    "title": "用戶配額使用",
     "daily": "每日",
     "monthly": "每月",
     "total": "總計"

+ 1 - 0
messages/zh-TW/settings/data.json

@@ -123,6 +123,7 @@
   },
   "status": {
     "connected": "資料庫連線正常",
+    "connectionUnavailable": "資料庫連線不可用,請檢查資料庫服務狀態",
     "error": "取得資料庫狀態失敗",
     "loading": "載入中...",
     "retry": "重試",

+ 2 - 2
messages/zh-TW/settings/providers/form/sections.json

@@ -285,9 +285,9 @@
         "placeholder": "1.0"
       },
       "group": {
-        "desc": "分組標籤。僅供 providerGroup 與此值相符的用戶使用。例:設為「premium」表示僅供 providerGroup=\"premium\" 的用戶使用",
+        "desc": "分組標籤。從列表選擇或輸入新名稱後按 Enter 創建(最多50字符)。只有 providerGroup 匹配的用戶才能使用此供應商。",
         "label": "供應商分組",
-        "placeholder": "例如premium, economy"
+        "placeholder": "例如 premium, economy"
       },
       "priority": {
         "desc": "數值越小,優先級越高(0 最高)。系統只會從最高優先級的供應商中選擇。建議:主力=0,備用=1,緊急備援=2",

+ 6 - 5
src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
 import * as React from "react";
 import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
 import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart";
@@ -41,6 +41,7 @@ export function StatisticsChartCard({
   className,
 }: StatisticsChartCardProps) {
   const t = useTranslations("dashboard.statistics");
+  const locale = useLocale();
   const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost");
   const [chartMode, setChartMode] = React.useState<"stacked" | "overlay">("overlay");
 
@@ -151,22 +152,22 @@ export function StatisticsChartCard({
   const formatDate = (dateStr: string) => {
     const date = new Date(dateStr);
     if (data.resolution === "hour") {
-      return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
+      return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
     }
-    return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
+    return date.toLocaleDateString(locale, { month: "numeric", day: "numeric" });
   };
 
   const formatTooltipDate = (dateStr: string) => {
     const date = new Date(dateStr);
     if (data.resolution === "hour") {
-      return date.toLocaleString("zh-CN", {
+      return date.toLocaleString(locale, {
         month: "long",
         day: "numeric",
         hour: "2-digit",
         minute: "2-digit",
       });
     }
-    return date.toLocaleDateString("zh-CN", {
+    return date.toLocaleDateString(locale, {
       year: "numeric",
       month: "long",
       day: "numeric",

+ 15 - 10
src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
 import * as React from "react";
 import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -11,24 +11,29 @@ export interface RateLimitEventsChartProps {
   data: EventTimeline[];
 }
 
-const chartConfig = {
-  count: {
-    label: "限流事件数",
-    color: "hsl(var(--chart-1))",
-  },
-} satisfies ChartConfig;
-
 /**
  * 限流事件时间线图表
  * 使用 Recharts AreaChart 显示小时级别的限流事件趋势
  */
 export function RateLimitEventsChart({ data }: RateLimitEventsChartProps) {
   const t = useTranslations("dashboard.rateLimits.chart");
+  const locale = useLocale();
+
+  const chartConfig = React.useMemo(
+    () =>
+      ({
+        count: {
+          label: t("events"),
+          color: "hsl(var(--chart-1))",
+        },
+      }) satisfies ChartConfig,
+    [t]
+  );
 
   // 格式化小时显示
   const formatHour = (hourStr: string) => {
     const date = new Date(hourStr);
-    return date.toLocaleTimeString("zh-CN", {
+    return date.toLocaleTimeString(locale, {
       month: "numeric",
       day: "numeric",
       hour: "2-digit",
@@ -39,7 +44,7 @@ export function RateLimitEventsChart({ data }: RateLimitEventsChartProps) {
   // 格式化 tooltip 显示
   const formatTooltipHour = (hourStr: string) => {
     const date = new Date(hourStr);
-    return date.toLocaleString("zh-CN", {
+    return date.toLocaleString(locale, {
       year: "numeric",
       month: "long",
       day: "numeric",

+ 4 - 3
src/app/[locale]/dashboard/_components/rate-limit-top-users.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { ArrowUpDown } from "lucide-react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
 import * as React from "react";
 import { getUsers } from "@/actions/users";
 import { Button } from "@/components/ui/button";
@@ -28,6 +28,7 @@ type SortDirection = "asc" | "desc";
  */
 export function RateLimitTopUsers({ data }: RateLimitTopUsersProps) {
   const t = useTranslations("dashboard.rateLimits.topUsers");
+  const locale = useLocale();
   const [users, setUsers] = React.useState<Array<{ id: number; name: string }>>([]);
   const [loading, setLoading] = React.useState(true);
   const [sortField, setSortField] = React.useState<SortField>("count");
@@ -53,14 +54,14 @@ export function RateLimitTopUsers({ data }: RateLimitTopUsersProps) {
       }))
       .sort((a, b) => {
         if (sortField === "name") {
-          const comparison = a.username.localeCompare(b.username, "zh-CN");
+          const comparison = a.username.localeCompare(b.username, locale);
           return sortDirection === "asc" ? comparison : -comparison;
         } else {
           const comparison = a.eventCount - b.eventCount;
           return sortDirection === "asc" ? comparison : -comparison;
         }
       });
-  }, [users, data, sortField, sortDirection]);
+  }, [users, data, sortField, sortDirection, locale]);
 
   // 切换排序
   const toggleSort = (field: SortField) => {

+ 6 - 5
src/app/[locale]/dashboard/_components/statistics/chart.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
 import * as React from "react";
 import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -57,6 +57,7 @@ export function UserStatisticsChart({
   currencyCode = "USD",
 }: UserStatisticsChartProps) {
   const t = useTranslations("dashboard.statistics");
+  const locale = useLocale();
   const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost");
   const [chartMode, setChartMode] = React.useState<"stacked" | "overlay">("overlay");
 
@@ -229,12 +230,12 @@ export function UserStatisticsChart({
   const formatDate = (dateStr: string) => {
     const date = new Date(dateStr);
     if (data.resolution === "hour") {
-      return date.toLocaleTimeString("zh-CN", {
+      return date.toLocaleTimeString(locale, {
         hour: "2-digit",
         minute: "2-digit",
       });
     } else {
-      return date.toLocaleDateString("zh-CN", {
+      return date.toLocaleDateString(locale, {
         month: "numeric",
         day: "numeric",
       });
@@ -245,14 +246,14 @@ export function UserStatisticsChart({
   const formatTooltipDate = (dateStr: string) => {
     const date = new Date(dateStr);
     if (data.resolution === "hour") {
-      return date.toLocaleString("zh-CN", {
+      return date.toLocaleString(locale, {
         month: "long",
         day: "numeric",
         hour: "2-digit",
         minute: "2-digit",
       });
     } else {
-      return date.toLocaleDateString("zh-CN", {
+      return date.toLocaleDateString(locale, {
         year: "numeric",
         month: "long",
         day: "numeric",

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

@@ -59,6 +59,10 @@ export interface KeyRowItemProps {
       model: string;
       callCount: number;
       totalCost: number;
+      inputTokens: number;
+      outputTokens: number;
+      cacheCreationTokens: number;
+      cacheReadTokens: number;
     }>;
   };
   /** User-level provider groups (used when key inherits providerGroup). */
@@ -429,11 +433,14 @@ export function KeyRowItem({
               </div>
             </TooltipTrigger>
             <TooltipContent side="bottom" align="start" className="max-w-[420px]">
-              <ul className="text-xs space-y-1 font-mono">
-                {effectiveGroups.map((group) => (
-                  <li key={group}>{group}</li>
-                ))}
-              </ul>
+              <div className="max-w-xs">
+                <p className="font-medium mb-1">{translations.fields.group}:</p>
+                <ul className="text-xs list-disc list-inside font-mono">
+                  {effectiveGroups.map((group) => (
+                    <li key={group}>{group}</li>
+                  ))}
+                </ul>
+              </div>
             </TooltipContent>
           </Tooltip>
         </div>

+ 169 - 43
src/app/[locale]/dashboard/_components/user/key-stats-dialog.tsx

@@ -1,7 +1,15 @@
 "use client";
 
+import {
+  Activity,
+  ArrowDownRight,
+  ArrowUpRight,
+  Coins,
+  Database,
+  Hash,
+  Target,
+} from "lucide-react";
 import { useTranslations } from "next-intl";
-import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import {
   Dialog,
@@ -11,20 +19,30 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
+import { Separator } from "@/components/ui/separator";
 import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
 
 export interface ModelStat {
   model: string;
   callCount: number;
   totalCost: number;
+  inputTokens: number;
+  outputTokens: number;
+  cacheCreationTokens: number;
+  cacheReadTokens: number;
+}
+
+function formatTokenAmount(tokens: number): string {
+  if (tokens >= 1_000_000_000) {
+    return `${(tokens / 1_000_000_000).toFixed(1)}B`;
+  }
+  if (tokens >= 1_000_000) {
+    return `${(tokens / 1_000_000).toFixed(1)}M`;
+  }
+  if (tokens >= 1_000) {
+    return `${(tokens / 1_000).toFixed(1)}K`;
+  }
+  return tokens.toLocaleString();
 }
 
 export interface KeyStatsDialogProps {
@@ -50,6 +68,11 @@ export function KeyStatsDialog({
 
   const totalCalls = modelStats.reduce((sum, stat) => sum + stat.callCount, 0);
   const totalCost = modelStats.reduce((sum, stat) => sum + stat.totalCost, 0);
+  const totalInput = modelStats.reduce((sum, stat) => sum + stat.inputTokens, 0);
+  const totalOutput = modelStats.reduce((sum, stat) => sum + stat.outputTokens, 0);
+  const totalCacheCreation = modelStats.reduce((sum, stat) => sum + stat.cacheCreationTokens, 0);
+  const totalCacheRead = modelStats.reduce((sum, stat) => sum + stat.cacheReadTokens, 0);
+  const totalTokens = totalInput + totalOutput + totalCacheCreation + totalCacheRead;
 
   const handleClose = () => {
     onOpenChange(false);
@@ -66,45 +89,148 @@ export function KeyStatsDialog({
         <div className="space-y-4">
           {modelStats.length > 0 ? (
             <>
-              <div className="rounded-md border">
-                <Table>
-                  <TableHeader>
-                    <TableRow>
-                      <TableHead>{t("columns.model")}</TableHead>
-                      <TableHead className="text-right">{t("columns.calls")}</TableHead>
-                      <TableHead className="text-right">{t("columns.cost")}</TableHead>
-                    </TableRow>
-                  </TableHeader>
-                  <TableBody>
-                    {modelStats.map((stat) => (
-                      <TableRow key={stat.model}>
-                        <TableCell className="font-mono text-xs">{stat.model}</TableCell>
-                        <TableCell className="text-right tabular-nums">
-                          {stat.callCount.toLocaleString()}
-                        </TableCell>
-                        <TableCell className="text-right font-mono tabular-nums">
-                          {formatCurrency(stat.totalCost, resolvedCurrencyCode)}
-                        </TableCell>
-                      </TableRow>
-                    ))}
-                  </TableBody>
-                </Table>
-              </div>
-
-              <div className="flex items-center justify-between px-2 text-sm">
-                <div className="flex items-center gap-2">
-                  <span className="text-muted-foreground">{t("totalCalls")}:</span>
-                  <Badge variant="secondary" className="tabular-nums">
+              <div className="grid grid-cols-3 gap-3">
+                <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Activity className="h-3.5 w-3.5" />
+                    {t("modal.requests")}
+                  </div>
+                  <div className="text-lg font-semibold font-mono">
                     {totalCalls.toLocaleString()}
-                  </Badge>
+                  </div>
                 </div>
-                <div className="flex items-center gap-2">
-                  <span className="text-muted-foreground">{t("totalCost")}:</span>
-                  <Badge variant="secondary" className="font-mono tabular-nums">
+
+                <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Hash className="h-3.5 w-3.5" />
+                    {t("modal.totalTokens")}
+                  </div>
+                  <div className="text-lg font-semibold font-mono">
+                    {formatTokenAmount(totalTokens)}
+                  </div>
+                </div>
+
+                <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Coins className="h-3.5 w-3.5" />
+                    {t("modal.cost")}
+                  </div>
+                  <div className="text-lg font-semibold font-mono">
                     {formatCurrency(totalCost, resolvedCurrencyCode)}
-                  </Badge>
+                  </div>
+                </div>
+              </div>
+
+              <Separator />
+
+              <div className="grid grid-cols-2 gap-3">
+                <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <ArrowUpRight className="h-3.5 w-3.5 text-blue-500" />
+                    {t("modal.inputTokens")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(totalInput)}
+                  </div>
+                </div>
+
+                <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <ArrowDownRight className="h-3.5 w-3.5 text-purple-500" />
+                    {t("modal.outputTokens")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(totalOutput)}
+                  </div>
+                </div>
+              </div>
+
+              <Separator />
+
+              <div className="space-y-2">
+                <h4 className="text-sm font-medium flex items-center gap-1.5">
+                  <Database className="h-4 w-4 text-muted-foreground" />
+                  {t("modal.cacheTokens")}
+                </h4>
+                <div className="grid grid-cols-2 gap-3">
+                  <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                    <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                      <Database className="h-3.5 w-3.5 text-orange-500" />
+                      {t("modal.cacheWrite")}
+                    </div>
+                    <div className="text-base font-semibold font-mono">
+                      {formatTokenAmount(totalCacheCreation)}
+                    </div>
+                  </div>
+
+                  <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                    <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                      <Database className="h-3.5 w-3.5 text-green-500" />
+                      {t("modal.cacheRead")}
+                    </div>
+                    <div className="text-base font-semibold font-mono">
+                      {formatTokenAmount(totalCacheRead)}
+                    </div>
+                  </div>
                 </div>
               </div>
+
+              <Separator />
+
+              <div className="space-y-2">
+                {modelStats.map((stat) => {
+                  const statTotalTokens =
+                    stat.inputTokens +
+                    stat.outputTokens +
+                    stat.cacheCreationTokens +
+                    stat.cacheReadTokens;
+                  const statTotalInput =
+                    stat.inputTokens + stat.cacheCreationTokens + stat.cacheReadTokens;
+                  const statCacheHitRate =
+                    statTotalInput > 0 ? (stat.cacheReadTokens / statTotalInput) * 100 : 0;
+                  const statCacheHitColor =
+                    statCacheHitRate >= 85
+                      ? "text-green-600 dark:text-green-400"
+                      : statCacheHitRate >= 60
+                        ? "text-yellow-600 dark:text-yellow-400"
+                        : "text-orange-600 dark:text-orange-400";
+                  const costPercentage =
+                    totalCost > 0 ? ((stat.totalCost / totalCost) * 100).toFixed(1) : "0.0";
+
+                  return (
+                    <div
+                      key={stat.model}
+                      className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors"
+                    >
+                      <div className="flex flex-col text-sm min-w-0 gap-1">
+                        <span className="font-medium text-foreground truncate font-mono text-xs">
+                          {stat.model}
+                        </span>
+                        <div className="flex items-center gap-3 text-xs text-muted-foreground">
+                          <span className="flex items-center gap-1">
+                            <Activity className="h-3 w-3" />
+                            {stat.callCount.toLocaleString()}
+                          </span>
+                          <span className="flex items-center gap-1">
+                            <Hash className="h-3 w-3" />
+                            {formatTokenAmount(statTotalTokens)}
+                          </span>
+                          <span className={`flex items-center gap-1 ${statCacheHitColor}`}>
+                            <Target className="h-3 w-3" />
+                            {statCacheHitRate.toFixed(1)}%
+                          </span>
+                        </div>
+                      </div>
+                      <div className="text-right text-sm font-semibold text-foreground whitespace-nowrap ml-2">
+                        <div>{formatCurrency(stat.totalCost, resolvedCurrencyCode)}</div>
+                        <div className="text-xs text-muted-foreground font-normal">
+                          ({costPercentage}%)
+                        </div>
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
             </>
           ) : (
             <div className="py-8 text-center text-sm text-muted-foreground">{t("noData")}</div>

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

@@ -12,7 +12,7 @@ import {
   XCircle,
 } from "lucide-react";
 import { useLocale, useTranslations } from "next-intl";
-import { useEffect, useState, useTransition } from "react";
+import { useEffect, useMemo, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { removeKey } from "@/actions/keys";
 import { toggleUserEnabled } from "@/actions/users";
@@ -106,6 +106,16 @@ function getExpiryStatus(
   return { label: "active", variant: "default" };
 }
 
+// Calculate days left until expiry (for user mode badge)
+function getDaysLeft(expiresAt: Date | null | undefined): number | null {
+  if (!expiresAt) return null;
+  const now = Date.now();
+  const expTs = expiresAt.getTime();
+  if (!Number.isFinite(expTs) || expTs <= now) return null;
+  const msLeft = expTs - now;
+  return Math.ceil(msLeft / (1000 * 60 * 60 * 24));
+}
+
 function normalizeLimitValue(value: unknown): number | null {
   const raw = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
   if (!Number.isFinite(raw) || raw <= 0) return null;
@@ -175,6 +185,10 @@ export function UserKeyTableRow({
   // 计算用户过期状态
   const expiryStatus = getExpiryStatus(localIsEnabled, localExpiresAt ?? null);
 
+  // 计算剩余天数(仅用于 user mode 显示)
+  const daysLeft = useMemo(() => getDaysLeft(localExpiresAt ?? null), [localExpiresAt]);
+  const showExpiryBadge = !isAdmin && daysLeft !== null && daysLeft <= 7;
+
   // 处理 Provider Group:拆分成数组
   const userGroups = splitGroups(user.providerGroup);
   const visibleGroups = userGroups.slice(0, MAX_VISIBLE_GROUPS);
@@ -332,11 +346,14 @@ export function UserKeyTableRow({
                   </div>
                 </TooltipTrigger>
                 <TooltipContent side="bottom" align="start">
-                  <ul className="text-xs space-y-1">
-                    {userGroups.map((group) => (
-                      <li key={group}>{group}</li>
-                    ))}
-                  </ul>
+                  <div className="max-w-xs">
+                    <p className="font-medium mb-1">{translations.keyRow?.fields?.group}:</p>
+                    <ul className="text-xs list-disc list-inside">
+                      {userGroups.map((group) => (
+                        <li key={group}>{group}</li>
+                      ))}
+                    </ul>
+                  </div>
                 </TooltipContent>
               </Tooltip>
             ) : null}
@@ -482,7 +499,27 @@ export function UserKeyTableRow({
                 <SquarePen className="h-4 w-4" />
               </Button>
             </>
-          ) : null}
+          ) : (
+            showExpiryBadge && (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <Badge
+                    variant={daysLeft === 0 ? "destructive" : "outline"}
+                    className={cn(
+                      "text-xs cursor-help",
+                      daysLeft > 0 &&
+                        daysLeft <= 7 &&
+                        "border-amber-500 text-amber-600 dark:text-amber-400"
+                    )}
+                  >
+                    <Clock className="h-3 w-3 mr-1" />
+                    {daysLeft}
+                  </Badge>
+                </TooltipTrigger>
+                <TooltipContent>{tUserStatus("daysLeft", { days: daysLeft })}</TooltipContent>
+              </Tooltip>
+            )
+          )}
         </div>
       </div>
 

+ 8 - 0
src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx

@@ -28,6 +28,14 @@ interface LimitUsageData {
 const usageCache = new Map<number, { data: LimitUsageData; timestamp: number }>();
 const CACHE_TTL = 60 * 1000; // 1 minute
 
+export function clearUsageCache(userId?: number): void {
+  if (userId !== undefined) {
+    usageCache.delete(userId);
+  } else {
+    usageCache.clear();
+  }
+}
+
 function formatPercentage(usage: number, limit: number): string {
   const percentage = Math.min(Math.round((usage / limit) * 100), 999);
   return `${percentage}%`;

+ 6 - 2
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -62,6 +62,7 @@ export interface UserManagementTableProps {
     editDialog: any;
     actions: {
       edit: string;
+      status: string;
       details: string;
       logs: string;
       delete: string;
@@ -501,8 +502,11 @@ export function UserManagementTable({
                   </span>
                 </div>
                 <div className="px-2 text-center min-w-0">
-                  <span className="block truncate" title={translations.actions.edit}>
-                    {translations.actions.edit}
+                  <span
+                    className="block truncate"
+                    title={isAdmin ? translations.actions.edit : translations.actions.status}
+                  >
+                    {isAdmin ? translations.actions.edit : translations.actions.status}
                   </span>
                 </div>
               </div>

+ 10 - 2
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -284,8 +284,16 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
     {
       header: t("columns.cacheHitRate"),
       className: "text-right",
-      cell: (row) =>
-        `${(Number((row as ProviderCacheHitRateEntry).cacheHitRate || 0) * 100).toFixed(1)}%`,
+      cell: (row) => {
+        const rate = Number((row as ProviderCacheHitRateEntry).cacheHitRate || 0) * 100;
+        const colorClass =
+          rate >= 85
+            ? "text-green-600 dark:text-green-400"
+            : rate >= 60
+              ? "text-yellow-600 dark:text-yellow-400"
+              : "text-orange-600 dark:text-orange-400";
+        return <span className={colorClass}>{rate.toFixed(1)}%</span>;
+      },
       sortKey: "cacheHitRate",
       getValue: (row) => (row as ProviderCacheHitRateEntry).cacheHitRate,
     },

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

@@ -199,10 +199,10 @@ export function VirtualizedLogsTable({
         {/* Fixed header */}
         <div className="bg-muted/50 border-b">
           <div className="flex items-center h-10 text-sm font-medium text-muted-foreground">
-            <div className="flex-[0.8] min-w-[80px] pl-2 truncate" title={t("logs.columns.time")}>
+            <div className="flex-[0.6] min-w-[56px] pl-2 truncate" title={t("logs.columns.time")}>
               {t("logs.columns.time")}
             </div>
-            <div className="flex-[0.6] min-w-[50px] px-1 truncate" title={t("logs.columns.user")}>
+            <div className="flex-[0.8] min-w-[50px] px-1 truncate" title={t("logs.columns.user")}>
               {t("logs.columns.user")}
             </div>
             <div className="flex-[0.6] min-w-[50px] px-1 truncate" title={t("logs.columns.key")}>
@@ -310,12 +310,12 @@ export function VirtualizedLogsTable({
                   )}
                 >
                   {/* Time */}
-                  <div className="flex-[0.8] min-w-[80px] font-mono text-xs truncate pl-2">
+                  <div className="flex-[0.6] min-w-[56px] font-mono text-xs truncate pl-2">
                     <RelativeTime date={log.createdAt} fallback="-" format="short" />
                   </div>
 
                   {/* User */}
-                  <div className="flex-[0.6] min-w-[50px] truncate px-1" title={log.userName}>
+                  <div className="flex-[0.8] min-w-[50px] truncate px-1" title={log.userName}>
                     {log.userName}
                   </div>
 

+ 6 - 1
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -27,6 +27,7 @@ import type { User, UserDisplay } from "@/types/user";
 import { AddKeyDialog } from "../_components/user/add-key-dialog";
 import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
 import { CreateUserDialog } from "../_components/user/create-user-dialog";
+import { clearUsageCache } from "../_components/user/user-limit-badge";
 import { UserManagementTable } from "../_components/user/user-management-table";
 
 const queryClient = new QueryClient({
@@ -469,6 +470,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
       editDialog: {},
       actions: {
         edit: tCommon("edit"),
+        status: tCommon("status"),
         details: tKeyList("detailsButton"),
         logs: tKeyList("logsButton"),
         delete: tCommon("delete"),
@@ -704,7 +706,10 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             onSelectKey={handleSelectKey}
             onOpenBatchEdit={handleOpenBatchEdit}
             translations={tableTranslations}
-            onRefresh={() => refetch()}
+            onRefresh={() => {
+              clearUsageCache();
+              refetch();
+            }}
             isRefreshing={isRefreshing}
           />
         </div>

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

@@ -83,7 +83,7 @@ export function UsageLogsTable({
                     {log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}
                   </TableCell>
                   <TableCell className="space-y-1">
-                    <div className="font-medium text-sm">{log.model ?? t("unknownModel")}</div>
+                    <div className="text-sm">{log.model ?? t("unknownModel")}</div>
                     {log.modelRedirect ? (
                       <div className="text-xs text-muted-foreground">{log.modelRedirect}</div>
                     ) : null}

+ 5 - 1
src/app/[locale]/settings/data/_components/database-status.tsx

@@ -23,6 +23,10 @@ export function DatabaseStatusDisplay() {
       });
 
       if (!response.ok) {
+        // Check 503 before parsing JSON (response may not have JSON body)
+        if (response.status === 503) {
+          throw new Error(t("connectionUnavailable"));
+        }
         const errorData = await response.json();
         throw new Error(errorData.error || t("error"));
       }
@@ -110,7 +114,7 @@ export function DatabaseStatusDisplay() {
       {/* Error message */}
       {status.error && (
         <div className="rounded-xl bg-orange-500/10 border border-orange-500/20 p-3 text-sm text-orange-400">
-          {status.error}
+          {status.isAvailable === false ? t("connectionUnavailable") : status.error}
         </div>
       )}
     </div>

+ 3 - 1
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx

@@ -91,7 +91,9 @@ export function TestingSection() {
               disabled={state.ui.isPending}
             >
               <SelectTrigger id={isEdit ? "edit-mcp-passthrough" : "mcp-passthrough"}>
-                <SelectValue />
+                <SelectValue>
+                  {t(`sections.mcpPassthrough.select.${state.mcp.mcpPassthroughType}.label`)}
+                </SelectValue>
               </SelectTrigger>
               <SelectContent>
                 <SelectItem value="none">

+ 2 - 2
src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx

@@ -418,8 +418,8 @@ function TestResultDetails({
       {(result.rawResponse || result.content) && (
         <div className="space-y-2">
           <h4 className="font-semibold text-sm">{t("resultCard.rawResponse.title")}</h4>
-          <div className="rounded-md border bg-muted/50 p-3 max-h-96 overflow-y-auto">
-            <pre className="text-xs whitespace-pre-wrap break-words font-mono">
+          <div className="rounded-md border bg-muted/50 p-3 max-h-96 overflow-auto">
+            <pre className="text-xs whitespace-pre-wrap break-all font-mono overflow-x-hidden">
               {result.rawResponse || result.content}
             </pre>
           </div>

+ 28 - 2
src/lib/provider-testing/test-service.ts

@@ -8,6 +8,7 @@
  * 3. Content validation (success_contains)
  */
 
+import { createProxyAgentForProvider, type ProviderProxyConfig } from "@/lib/proxy-agent";
 import { getPreset, getPresetPayload } from "./presets";
 import type {
   ProviderTestConfig,
@@ -77,19 +78,42 @@ export async function executeProviderTest(config: ProviderTestConfig): Promise<P
   const baseHeaders = getTestHeaders(config.providerType, config.apiKey);
   const headers = config.customHeaders ? { ...baseHeaders, ...config.customHeaders } : baseHeaders;
 
+  // Track proxy usage (declared outside try for catch block access)
+  let usedProxy = false;
+
   try {
+    // Create proxy agent if proxy URL is configured
+    let dispatcher: unknown | undefined;
+    if (config.proxyUrl) {
+      const tempProvider: ProviderProxyConfig = {
+        id: -1,
+        name: "test-provider",
+        proxyUrl: config.proxyUrl,
+        proxyFallbackToDirect: config.proxyFallbackToDirect ?? false,
+      };
+      const proxyConfig = createProxyAgentForProvider(tempProvider, url);
+      if (proxyConfig) {
+        dispatcher = proxyConfig.agent;
+        usedProxy = true;
+      }
+    }
+
     // Create abort controller for timeout
     const controller = new AbortController();
     const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
 
     try {
       // Execute request
-      const response = await fetch(url, {
+      const fetchOptions: RequestInit & { dispatcher?: unknown } = {
         method: "POST",
         headers,
         body: JSON.stringify(body),
         signal: controller.signal,
-      });
+      };
+      if (dispatcher) {
+        fetchOptions.dispatcher = dispatcher;
+      }
+      const response = await fetch(url, fetchOptions);
 
       firstByteMs = Date.now() - startTime;
 
@@ -145,6 +169,7 @@ export async function executeProviderTest(config: ProviderTestConfig): Promise<P
         rawResponse: responseBody.slice(0, 5000), // Full response for detailed inspection
         testedAt: new Date(),
         validationDetails,
+        usedProxy,
       };
     } finally {
       clearTimeout(timeoutId);
@@ -175,6 +200,7 @@ export async function executeProviderTest(config: ProviderTestConfig): Promise<P
       rawError: error,
       testedAt: new Date(),
       validationDetails,
+      usedProxy,
     };
   }
 }

+ 29 - 1
src/repository/key.ts

@@ -566,6 +566,10 @@ export interface KeyStatistics {
     model: string;
     callCount: number;
     totalCost: number;
+    inputTokens: number;
+    outputTokens: number;
+    cacheCreationTokens: number;
+    cacheReadTokens: number;
   }>;
 }
 
@@ -618,6 +622,10 @@ export async function findKeysWithStatistics(userId: number): Promise<KeyStatist
         model: messageRequest.model,
         callCount: sql<number>`count(*)::int`,
         totalCost: sum(messageRequest.costUsd),
+        inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
+        outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+        cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
+        cacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
       })
       .from(messageRequest)
       .where(
@@ -640,6 +648,10 @@ export async function findKeysWithStatistics(userId: number): Promise<KeyStatist
         const costDecimal = toCostDecimal(row.totalCost) ?? new Decimal(0);
         return costDecimal.toDecimalPlaces(6).toNumber();
       })(),
+      inputTokens: row.inputTokens,
+      outputTokens: row.outputTokens,
+      cacheCreationTokens: row.cacheCreationTokens,
+      cacheReadTokens: row.cacheReadTokens,
     }));
 
     stats.push({
@@ -759,6 +771,10 @@ export async function findKeysWithStatisticsBatch(
       model: messageRequest.model,
       callCount: sql<number>`count(*)::int`,
       totalCost: sum(messageRequest.costUsd),
+      inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
+      outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+      cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
+      cacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
     })
     .from(messageRequest)
     .where(
@@ -777,7 +793,15 @@ export async function findKeysWithStatisticsBatch(
   // Group model stats by key
   const modelStatsMap = new Map<
     string,
-    Array<{ model: string; callCount: number; totalCost: number }>
+    Array<{
+      model: string;
+      callCount: number;
+      totalCost: number;
+      inputTokens: number;
+      outputTokens: number;
+      cacheCreationTokens: number;
+      cacheReadTokens: number;
+    }>
   >();
   for (const row of modelStatsRows) {
     if (row.key) {
@@ -791,6 +815,10 @@ export async function findKeysWithStatisticsBatch(
           const costDecimal = toCostDecimal(row.totalCost) ?? new Decimal(0);
           return costDecimal.toDecimalPlaces(6).toNumber();
         })(),
+        inputTokens: row.inputTokens,
+        outputTokens: row.outputTokens,
+        cacheCreationTokens: row.cacheCreationTokens,
+        cacheReadTokens: row.cacheReadTokens,
       });
     }
   }

+ 4 - 0
src/types/user.ts

@@ -107,6 +107,10 @@ export interface UserKeyDisplay {
     model: string;
     callCount: number;
     totalCost: number;
+    inputTokens: number;
+    outputTokens: number;
+    cacheCreationTokens: number;
+    cacheReadTokens: number;
   }>; // 各模型统计(当天)
   createdAt: Date; // 创建时间
   createdAtFormatted: string; // 格式化后的具体时间