diff --git a/messages/en/auth.json b/messages/en/auth.json index cd9ea7f96..460d311d0 100644 --- a/messages/en/auth.json +++ b/messages/en/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "Solutions:", "useHttps": "Use HTTPS to access the system (recommended)", "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": { "loginFailed": "Login failed", @@ -38,6 +38,9 @@ "invalidToken": "Invalid authentication token", "tokenRequired": "Authentication token is required", "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" } } diff --git a/messages/en/common.json b/messages/en/common.json index cb72d523b..c12dd0b66 100644 --- a/messages/en/common.json +++ b/messages/en/common.json @@ -48,5 +48,15 @@ "theme": "Theme", "light": "Light", "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" + } } diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 91ef7163a..ec7785f86 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -781,6 +781,15 @@ "defaultDescription": "default includes providers without groupTag.", "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", "successDescription": "Your API key has been created successfully.", "generatedKey": { @@ -1144,18 +1153,21 @@ "name": "Key name", "key": "Key", "group": "Group", - "todayUsage": "Today's usage", + "todayUsage": "Requests today", "todayCost": "Today's cost", + "todayTokens": "Tokens today", "lastUsed": "Last used", "actions": "Actions", "quotaButton": "View Quota Usage", "fields": { - "callsLabel": "Calls", + "callsLabel": "Requests", + "tokensLabel": "Tokens", "costLabel": "Cost" } }, "expand": "Expand", "collapse": "Collapse", + "refresh": "Refresh", "noKeys": "No keys", "defaultGroup": "default", "userStatus": { @@ -1246,7 +1258,18 @@ "userEnabled": "User has been enabled", "deleteFailed": "Failed to delete user", "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": { "enterMode": "Batch Edit", @@ -1347,6 +1370,41 @@ }, "limitRules": { "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": { "limitRpm": "RPM limit", "limit5h": "5-hour limit", @@ -1356,6 +1414,12 @@ "limitTotal": "Total limit", "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": { "fixed": "Fixed reset time", "rolling": "Rolling window (24h)" @@ -1368,8 +1432,7 @@ "500": "$500" }, "alreadySet": "Configured", - "confirmAdd": "Add", - "cancel": "Cancel" + "confirmAdd": "Add" }, "quickExpire": { "oneWeek": "In 1 week", @@ -1592,6 +1655,13 @@ } }, "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" } } }, diff --git a/messages/en/quota.json b/messages/en/quota.json index 78332d6ca..d50b2b534 100644 --- a/messages/en/quota.json +++ b/messages/en/quota.json @@ -288,7 +288,8 @@ "limit5hUsd": { "label": "5-Hour Cost Limit (USD)", "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": { "label": "Daily Cost Limit (USD)", @@ -314,12 +315,14 @@ "limitWeeklyUsd": { "label": "Weekly Cost Limit (USD)", "placeholder": "Leave blank for unlimited", - "description": "Maximum cost per week" + "description": "Maximum cost per week", + "descriptionWithUserLimit": "Cannot exceed user weekly limit ({limit})" }, "limitMonthlyUsd": { "label": "Monthly Cost Limit (USD)", "placeholder": "Leave blank for unlimited", - "description": "Maximum cost per month" + "description": "Maximum cost per month", + "descriptionWithUserLimit": "Cannot exceed user monthly limit ({limit})" }, "limitTotalUsd": { "label": "Total Cost Limit (USD)", @@ -330,7 +333,8 @@ "limitConcurrentSessions": { "label": "Concurrent Session Limit", "placeholder": "0 means unlimited", - "description": "Number of simultaneous conversations" + "description": "Number of simultaneous conversations", + "descriptionWithUserLimit": "Cannot exceed user session limit ({limit})" }, "providerGroup": { "label": "Provider Group", diff --git a/messages/en/settings/providers/autoSort.json b/messages/en/settings/providers/autoSort.json index f3aae3bd7..c3097a3e9 100644 --- a/messages/en/settings/providers/autoSort.json +++ b/messages/en/settings/providers/autoSort.json @@ -1,5 +1,5 @@ { - "button": "Auto Sort Priority", + "button": "Auto Sort", "changeCount": "{count} providers will be updated", "changesTitle": "Change Details", "confirm": "Apply Changes", diff --git a/messages/ja/auth.json b/messages/ja/auth.json index ef0f33d34..113aa9193 100644 --- a/messages/ja/auth.json +++ b/messages/ja/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "解決策:", "useHttps": "HTTPS を使用してアクセスしてください (推奨)", "disableSecureCookies": ".env ファイルで ENABLE_SECURE_COOKIES=false を設定 (セキュリティが低下します)", - "privacyNote": "API Keyを使用してClaude Code Hub管理画面にログインしてください" + "privacyNote": "API Keyを使用して管理画面にログインしてください" }, "errors": { "loginFailed": "ログインに失敗しました", @@ -38,6 +38,9 @@ "invalidToken": "無効な認証トークン", "tokenRequired": "認証トークンが必要です", "sessionExpired": "セッションの有効期限が切れています。もう一度ログインしてください", - "unauthorized": "認可されていません。先にログインしてください" + "unauthorized": "認可されていません。先にログインしてください", + "apiKeyRequired": "API Keyを入力してください", + "apiKeyInvalidOrExpired": "API Keyが無効または期限切れです", + "serverError": "ログインに失敗しました。しばらく後に再度お試しください" } } diff --git a/messages/ja/common.json b/messages/ja/common.json index f6a762258..c3442e2db 100644 --- a/messages/ja/common.json +++ b/messages/ja/common.json @@ -48,5 +48,15 @@ "theme": "テーマ", "light": "ライト", "dark": "ダーク", - "system": "システム設定" + "system": "システム設定", + "relativeTimeShort": { + "now": "たった今", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}時間前", + "daysAgo": "{count}日前", + "weeksAgo": "{count}週間前", + "monthsAgo": "{count}ヶ月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index bc0467dc5..12d1d52c9 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -717,7 +717,8 @@ "limit5hUsd": { "label": "5時間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "5時間以内の最大消費金額" + "description": "5時間以内の最大消費金額", + "descriptionWithUserLimit": "5時間以内の最大消費金額 (ユーザー上限: {limit})" }, "limitDailyUsd": { "label": "1日の消費上限 (USD)", @@ -743,17 +744,26 @@ "limitWeeklyUsd": { "label": "週間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "1週間あたりの最大消費金額" + "description": "1週間あたりの最大消費金額", + "descriptionWithUserLimit": "1週間あたりの最大消費金額 (ユーザー上限: {limit})" }, "limitMonthlyUsd": { "label": "月間消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "1ヶ月あたりの最大消費金額" + "description": "1ヶ月あたりの最大消費金額", + "descriptionWithUserLimit": "1ヶ月あたりの最大消費金額 (ユーザー上限: {limit})" + }, + "limitTotalUsd": { + "label": "総消費上限 (USD)", + "placeholder": "空白の場合は無制限", + "description": "累計消費上限(リセットなし)", + "descriptionWithUserLimit": "ユーザーの総上限を超えることはできません ({limit})" }, "limitConcurrentSessions": { "label": "同時セッション上限", "placeholder": "0は無制限を意味します", - "description": "同時に実行される会話の数" + "description": "同時に実行される会話の数", + "descriptionWithUserLimit": "最大セッション数 (ユーザー上限: {limit})" }, "providerGroup": { "label": "プロバイダーグループ", @@ -762,6 +772,15 @@ "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます", "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)" }, + "cacheTtl": { + "label": "Cache TTL上書き", + "description": "cache_controlを含むリクエストに対してAnthropic prompt cache TTLを強制します。", + "options": { + "inherit": "上書きしない(プロバイダー/クライアントに従う)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "キーが正常に作成されました", "successDescription": "APIキーが正常に作成されました。", "generatedKey": { @@ -1115,18 +1134,21 @@ "name": "キー名", "key": "キー", "group": "グループ", - "todayUsage": "本日の使用量", + "todayUsage": "本日のリクエスト", "todayCost": "本日の消費", + "todayTokens": "本日のトークン", "lastUsed": "最終使用", "actions": "アクション", "quotaButton": "クォータ使用状況を表示", "fields": { - "callsLabel": "呼び出し", + "callsLabel": "リクエスト", + "tokensLabel": "トークン", "costLabel": "消費" } }, "expand": "展開", "collapse": "折りたたむ", + "refresh": "更新", "noKeys": "キーなし", "defaultGroup": "default", "userStatus": { @@ -1178,6 +1200,10 @@ "currentExpiry": "現在の有効期限", "neverExpires": "無期限", "expired": "期限切れ", + "quickExtensionLabel": "クイック延長", + "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)", + "customDateLabel": "有効期限を設定", + "customDateHint": "有効期限を直接指定", "quickOptions": { "7days": "7 日", "30days": "30 日", @@ -1186,6 +1212,7 @@ }, "customDate": "カスタム日付", "enableOnRenew": "同時にユーザーを有効化", + "enableKeyOnRenew": "同時にキーを有効化", "cancel": "キャンセル", "confirm": "更新を確認", "confirming": "更新中...", @@ -1208,7 +1235,18 @@ "userEnabled": "ユーザーが有効化されました", "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", - "saving": "保存しています..." + "saving": "保存しています...", + "resetData": { + "title": "統計リセット", + "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。", + "error": "データのリセットに失敗しました", + "button": "統計をリセット", + "confirmTitle": "すべての統計をリセットしますか?", + "confirmDescription": "このユーザーのすべてのリクエストログと使用統計を完全に削除します。この操作は取り消せません。", + "confirm": "はい、すべてリセット", + "loading": "リセット中...", + "success": "すべての統計がリセットされました" + } }, "batchEdit": { "enterMode": "一括編集", diff --git a/messages/ja/quota.json b/messages/ja/quota.json index 4994ea9d6..874c033bf 100644 --- a/messages/ja/quota.json +++ b/messages/ja/quota.json @@ -265,7 +265,8 @@ "limit5hUsd": { "label": "5時間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "5時間以内の最大消費金額" + "description": "5時間以内の最大消費金額", + "descriptionWithUserLimit": "ユーザーの5時間制限を超えることはできません ({limit})" }, "limitDailyUsd": { "label": "日次消費上限 (USD)", @@ -291,17 +292,26 @@ "limitWeeklyUsd": { "label": "週間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "毎週の最大消費金額" + "description": "毎週の最大消費金額", + "descriptionWithUserLimit": "ユーザーの週間制限を超えることはできません ({limit})" }, "limitMonthlyUsd": { "label": "月間消費上限 (USD)", "placeholder": "空欄の場合は無制限", - "description": "毎月の最大消費金額" + "description": "毎月の最大消費金額", + "descriptionWithUserLimit": "ユーザーの月間制限を超えることはできません ({limit})" + }, + "limitTotalUsd": { + "label": "総消費上限 (USD)", + "placeholder": "空欄の場合は無制限", + "description": "累計消費上限(リセットなし)", + "descriptionWithUserLimit": "ユーザーの総制限を超えることはできません ({limit})" }, "limitConcurrentSessions": { "label": "同時セッション上限", "placeholder": "0 = 無制限", - "description": "同時実行可能な会話数" + "description": "同時実行可能な会話数", + "descriptionWithUserLimit": "ユーザーのセッション制限を超えることはできません ({limit})" }, "providerGroup": { "label": "プロバイダーグループ", diff --git a/messages/ru/auth.json b/messages/ru/auth.json index 6e18bdc15..4e6f42542 100644 --- a/messages/ru/auth.json +++ b/messages/ru/auth.json @@ -1,7 +1,7 @@ { "form": { "title": "Панель входа", - "description": "Получите доступ к унифицированной консоли администратора с помощью вашего API ключа" + "description": "Введите ваш API ключ для доступа к данным" }, "login": { "title": "Вход", @@ -30,7 +30,7 @@ "solutionTitle": "Решения:", "useHttps": "Используйте HTTPS для доступа к системе (рекомендуется)", "disableSecureCookies": "Установите ENABLE_SECURE_COOKIES=false в .env (снижает безопасность)", - "privacyNote": "Пожалуйста, используйте свой API Key для входа в панель администрирования Claude Code Hub" + "privacyNote": "Если вы забыли свой API ключ, обратитесь к администратору" }, "errors": { "loginFailed": "Ошибка входа", @@ -38,6 +38,9 @@ "invalidToken": "Неверный токен аутентификации", "tokenRequired": "Требуется токен аутентификации", "sessionExpired": "Ваша сессия истекла, пожалуйста, войдите снова", - "unauthorized": "Не авторизовано, пожалуйста, сначала войдите" + "unauthorized": "Не авторизовано, пожалуйста, сначала войдите", + "apiKeyRequired": "Пожалуйста, введите API ключ", + "apiKeyInvalidOrExpired": "API ключ недействителен или истёк", + "serverError": "Ошибка входа, попробуйте позже" } } diff --git a/messages/ru/common.json b/messages/ru/common.json index 55deada2f..86c097d5c 100644 --- a/messages/ru/common.json +++ b/messages/ru/common.json @@ -48,5 +48,15 @@ "theme": "Тема", "light": "Светлая", "dark": "Тёмная", - "system": "Системная" + "system": "Системная", + "relativeTimeShort": { + "now": "сейчас", + "secondsAgo": "{count}с назад", + "minutesAgo": "{count}м назад", + "hoursAgo": "{count}ч назад", + "daysAgo": "{count}д назад", + "weeksAgo": "{count}н назад", + "monthsAgo": "{count}мес назад", + "yearsAgo": "{count}г назад" + } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1b332bc7e..5150bcf1e 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -532,11 +532,11 @@ "dashboard": "Панель", "usageLogs": "Журналы", "leaderboard": "Лидеры", - "availability": "Доступность", + "availability": "Мониторинг", "myQuota": "Моя квота", "quotasManagement": "Квоты", "userManagement": "Пользователи", - "providers": "Управление поставщиками", + "providers": "Поставщики", "documentation": "Доки", "systemSettings": "Настройки", "feedback": "Обратная связь", @@ -544,7 +544,7 @@ "logout": "Выход" }, "statistics": { - "title": "Статистика использования", + "title": "Статистика", "cost": "Сумма расходов", "calls": "Количество вызовов API", "totalCost": "Общая сумма расходов", @@ -552,18 +552,18 @@ "timeRange": { "today": "Сегодня", "todayDescription": "Использование за сегодня", - "7days": "Последние 7 дней", + "7days": "7д", "7daysDescription": "Использование за последние 7 дней", - "30days": "Последние 30 дней", + "30days": "30д", "30daysDescription": "Использование за последние 30 дней", "thisMonth": "Этот месяц", "thisMonthDescription": "Использование за этот месяц", "default": "Использование" }, "mode": { - "keys": "Показать статистику использования только для ваших ключей", + "keys": "Только ваши ключи", "mixed": "Показать детали ваших ключей и сводку других пользователей", - "users": "Показать статистику использования всех пользователей" + "users": "Показать для всех" }, "legend": { "selectAll": "Выбрать все", @@ -719,7 +719,8 @@ "limit5hUsd": { "label": "Лимит расходов за 5 часов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в течение 5 часов" + "description": "Максимальный расход в течение 5 часов", + "descriptionWithUserLimit": "Максимальный расход за 5 часов (Лимит пользователя: {limit})" }, "limitDailyUsd": { "label": "Дневной лимит расходов (USD)", @@ -745,17 +746,26 @@ "limitWeeklyUsd": { "label": "Недельный лимит расходов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в неделю" + "description": "Максимальный расход в неделю", + "descriptionWithUserLimit": "Максимальный расход в неделю (Лимит пользователя: {limit})" }, "limitMonthlyUsd": { "label": "Месячный лимит расходов (USD)", "placeholder": "Оставьте пустым для неограниченного", - "description": "Максимальный расход в месяц" + "description": "Максимальный расход в месяц", + "descriptionWithUserLimit": "Максимальный расход в месяц (Лимит пользователя: {limit})" + }, + "limitTotalUsd": { + "label": "Общий лимит расходов (USD)", + "placeholder": "Оставьте пустым для неограниченного", + "description": "Максимальная сумма расходов (без сброса)", + "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})" }, "limitConcurrentSessions": { "label": "Лимит параллельных сеансов", "placeholder": "0 означает неограниченно", - "description": "Количество одновременных разговоров" + "description": "Количество одновременных разговоров", + "descriptionWithUserLimit": "Максимум сеансов (Лимит пользователя: {limit})" }, "providerGroup": { "label": "Группа провайдеров", @@ -764,6 +774,15 @@ "defaultDescription": "default включает провайдеров без groupTag.", "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)." }, + "cacheTtl": { + "label": "Переопределение Cache TTL", + "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.", + "options": { + "inherit": "Не переопределять (следовать провайдеру/клиенту)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "Ключ успешно создан", "successDescription": "Ваш API-ключ был успешно создан.", "generatedKey": { @@ -918,7 +937,7 @@ "last1h": "Последний час", "last6h": "Последние 6 часов", "last24h": "Последние 24 часа", - "last7d": "Последние 7 дней", + "last7d": "7д", "custom": "Настраиваемый" }, "filters": { @@ -1122,18 +1141,21 @@ "name": "Название ключа", "key": "Ключ", "group": "Группа", - "todayUsage": "Использование сегодня", + "todayUsage": "Запросы сегодня", "todayCost": "Расход сегодня", + "todayTokens": "Токены сегодня", "lastUsed": "Последнее использование", "actions": "Действия", "quotaButton": "Просмотр использования квоты", "fields": { - "callsLabel": "Вызовы", + "callsLabel": "Запросы", + "tokensLabel": "Токены", "costLabel": "Расход" } }, "expand": "Развернуть", "collapse": "Свернуть", + "refresh": "Обновить", "noKeys": "Нет ключей", "defaultGroup": "default", "userStatus": { @@ -1185,6 +1207,10 @@ "currentExpiry": "Текущий срок", "neverExpires": "Бессрочно", "expired": "Истёк", + "quickExtensionLabel": "Быстрое продление", + "quickExtensionHint": "Продлить от текущего срока (или от сейчас, если истёк)", + "customDateLabel": "Указать дату", + "customDateHint": "Напрямую указать дату истечения", "quickOptions": { "7days": "7 дней", "30days": "30 дней", @@ -1193,6 +1219,7 @@ }, "customDate": "Произвольная дата", "enableOnRenew": "Также включить пользователя", + "enableKeyOnRenew": "Также включить ключ", "cancel": "Отмена", "confirm": "Подтвердить продление", "confirming": "Продление...", @@ -1219,7 +1246,18 @@ "userEnabled": "Пользователь активирован", "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", - "saving": "Сохранение..." + "saving": "Сохранение...", + "resetData": { + "title": "Сброс статистики", + "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.", + "error": "Не удалось сбросить данные", + "button": "Сбросить статистику", + "confirmTitle": "Сбросить всю статистику?", + "confirmDescription": "Это навсегда удалит все логи запросов и статистику использования для этого пользователя. Это действие нельзя отменить.", + "confirm": "Да, сбросить все", + "loading": "Сброс...", + "success": "Вся статистика сброшена" + } }, "batchEdit": { "enterMode": "Массовое редактирование", @@ -1320,6 +1358,41 @@ }, "limitRules": { "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": { "limitRpm": "Лимит RPM", "limit5h": "Лимит за 5 часов", @@ -1329,6 +1402,12 @@ "limitTotal": "Общий лимит", "limitSessions": "Одновременные сессии" }, + "errors": { + "missingType": "Пожалуйста, выберите тип лимита", + "invalidValue": "Пожалуйста, введите корректное значение", + "invalidTime": "Пожалуйста, введите корректное время (ЧЧ:мм)" + }, + "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение", "dailyMode": { "fixed": "Сброс по фиксированному времени", "rolling": "Скользящее окно (24ч)" @@ -1341,8 +1420,7 @@ "500": "$500" }, "alreadySet": "Уже настроено", - "confirmAdd": "Добавить", - "cancel": "Отмена" + "confirmAdd": "Добавить" }, "quickExpire": { "oneWeek": "Через неделю", @@ -1531,7 +1609,9 @@ }, "balanceQueryPage": { "label": "Независимая страница использования", - "description": "При включении этот ключ может использовать независимую страницу личного использования" + "description": "При включении этот ключ может использовать независимую страницу личного использования", + "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.", + "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI." }, "providerGroup": { "label": "Группа провайдеров", @@ -1564,6 +1644,13 @@ } }, "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение" + }, + "accessRestrictions": { + "title": "Ограничения доступа", + "models": "Разрешённые модели", + "clients": "Разрешённые клиенты", + "noRestrictions": "Без ограничений", + "inheritedFromUser": "Унаследовано от настроек пользователя" } } }, diff --git a/messages/ru/quota.json b/messages/ru/quota.json index 3b66d416c..293e2d2fb 100644 --- a/messages/ru/quota.json +++ b/messages/ru/quota.json @@ -64,6 +64,8 @@ "users": { "title": "Статистика квот пользователей", "totalCount": "Всего пользователей: {count}", + "manageNotice": "Для управления пользователями и ключами перейдите в", + "manageLink": "Управление пользователями", "noNote": "Без заметок", "rpm": { "label": "RPM квота", @@ -85,7 +87,30 @@ "warning": "Приближение к лимиту (>60%)", "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": { "title": "Статистика квот провайдеров", @@ -263,7 +288,8 @@ "limit5hUsd": { "label": "Лимит расходов за 5 часов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов за 5 часов" + "description": "Максимальная сумма расходов за 5 часов", + "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})" }, "limitDailyUsd": { "label": "Дневной лимит расходов (USD)", @@ -289,17 +315,26 @@ "limitWeeklyUsd": { "label": "Еженедельный лимит расходов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов в неделю" + "description": "Максимальная сумма расходов в неделю", + "descriptionWithUserLimit": "Не может превышать недельный лимит пользователя ({limit})" }, "limitMonthlyUsd": { "label": "Ежемесячный лимит расходов (USD)", "placeholder": "Оставьте пустым для отсутствия ограничений", - "description": "Максимальная сумма расходов в месяц" + "description": "Максимальная сумма расходов в месяц", + "descriptionWithUserLimit": "Не может превышать месячный лимит пользователя ({limit})" + }, + "limitTotalUsd": { + "label": "Общий лимит расходов (USD)", + "placeholder": "Оставьте пустым для отсутствия ограничений", + "description": "Максимальная сумма расходов (без сброса)", + "descriptionWithUserLimit": "Не может превышать общий лимит пользователя ({limit})" }, "limitConcurrentSessions": { "label": "Лимит параллельных сессий", "placeholder": "0 = без ограничений", - "description": "Количество одновременных диалогов" + "description": "Количество одновременных диалогов", + "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})" }, "providerGroup": { "label": "Группа провайдеров", diff --git a/messages/ru/settings/providers/autoSort.json b/messages/ru/settings/providers/autoSort.json index cecb10d7f..b0f852ad8 100644 --- a/messages/ru/settings/providers/autoSort.json +++ b/messages/ru/settings/providers/autoSort.json @@ -1,5 +1,5 @@ { - "button": "Авто сортировка приоритета", + "button": "Автосорт", "changeCount": "{count} поставщиков будет обновлено", "changesTitle": "Детали изменений", "confirm": "Применить изменения", diff --git a/messages/ru/settings/providers/form/title.json b/messages/ru/settings/providers/form/title.json index 44ef32746..9f710acbb 100644 --- a/messages/ru/settings/providers/form/title.json +++ b/messages/ru/settings/providers/form/title.json @@ -1,4 +1,4 @@ { - "create": "Добавить провайдера", - "edit": "Редактировать провайдера" + "create": "Добавить поставщика", + "edit": "Редактировать поставщика" } diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index c9374633a..2ddcf758e 100644 --- a/messages/ru/settings/providers/strings.json +++ b/messages/ru/settings/providers/strings.json @@ -1,7 +1,7 @@ { "add": "Добавить поставщика", "addFailed": "Ошибка добавления поставщика", - "addProvider": "Добавить провайдера", + "addProvider": "Добавить поставщика", "addSuccess": "Поставщик добавлен успешно", "circuitBroken": "Цепь разомкнута", "clone": "Дублировать поставщика", @@ -10,7 +10,7 @@ "confirmDeleteDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть отменено.", "confirmDeleteProvider": "Подтвердить удаление провайдера?", "confirmDeleteProviderDesc": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие не может быть восстановлено.", - "createProvider": "Добавить провайдера", + "createProvider": "Добавить поставщика", "delete": "Удалить поставщика", "deleteFailed": "Ошибка удаления поставщика", "deleteSuccess": "Успешно удалено", diff --git a/messages/zh-CN/auth.json b/messages/zh-CN/auth.json index 032c1976b..9ffb12e4f 100644 --- a/messages/zh-CN/auth.json +++ b/messages/zh-CN/auth.json @@ -19,7 +19,10 @@ "invalidToken": "无效的认证令牌", "tokenRequired": "需要提供认证令牌", "sessionExpired": "会话已过期,请重新登录", - "unauthorized": "未授权,请先登录" + "unauthorized": "未授权,请先登录", + "apiKeyRequired": "请输入 API Key", + "apiKeyInvalidOrExpired": "API Key 无效或已过期", + "serverError": "登录失败,请稍后重试" }, "placeholders": { "apiKeyExample": "例如 sk-xxxxxxxx" @@ -34,7 +37,7 @@ "solutionTitle": "解决方案:", "useHttps": "使用 HTTPS 访问(推荐)", "disableSecureCookies": "在 .env 中设置 ENABLE_SECURE_COOKIES=false(会降低安全性)", - "privacyNote": "请使用您的 API Key 登录 Claude Code Hub 后台" + "privacyNote": "请使用您的 API Key 登录后台" }, "form": { "title": "登录面板", diff --git a/messages/zh-CN/common.json b/messages/zh-CN/common.json index 43b4c78eb..75c7c9abd 100644 --- a/messages/zh-CN/common.json +++ b/messages/zh-CN/common.json @@ -48,5 +48,15 @@ "theme": "主题", "light": "浅色", "dark": "深色", - "system": "跟随系统" + "system": "跟随系统", + "relativeTimeShort": { + "now": "刚刚", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}时前", + "daysAgo": "{count}天前", + "weeksAgo": "{count}周前", + "monthsAgo": "{count}月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 40b1be77a..fef7e39cf 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -782,6 +782,15 @@ "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商", "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})" }, + "cacheTtl": { + "label": "Cache TTL 覆写", + "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。", + "options": { + "inherit": "不覆写(跟随供应商/客户端)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "密钥创建成功", "successDescription": "您的 API 密钥已成功创建。", "generatedKey": { @@ -1145,18 +1154,21 @@ "name": "密钥名称", "key": "密钥", "group": "分组", - "todayUsage": "今日用量", + "todayUsage": "今日请求", "todayCost": "今日消耗", + "todayTokens": "今日Token", "lastUsed": "最后使用", "actions": "操作", "quotaButton": "查看限额用量", "fields": { - "callsLabel": "调用", + "callsLabel": "请求", + "tokensLabel": "Token", "costLabel": "消耗" } }, "expand": "展开", "collapse": "收起", + "refresh": "刷新", "noKeys": "无密钥", "defaultGroup": "default", "userStatus": { @@ -1247,7 +1259,18 @@ "userEnabled": "用户已启用", "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", - "saving": "保存中..." + "saving": "保存中...", + "resetData": { + "title": "重置统计", + "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。", + "error": "重置数据失败", + "button": "重置统计", + "confirmTitle": "重置所有统计?", + "confirmDescription": "这将永久删除该用户的所有请求日志和使用统计。此操作无法撤销。", + "confirm": "是的,重置全部", + "loading": "重置中...", + "success": "所有统计已重置" + } }, "batchEdit": { "enterMode": "批量编辑", diff --git a/messages/zh-TW/auth.json b/messages/zh-TW/auth.json index f48160f9b..58da807c1 100644 --- a/messages/zh-TW/auth.json +++ b/messages/zh-TW/auth.json @@ -30,7 +30,7 @@ "solutionTitle": "解決方案:", "useHttps": "使用 HTTPS 存取(推薦)", "disableSecureCookies": "在 .env 中設定 ENABLE_SECURE_COOKIES=false(會降低安全性)", - "privacyNote": "請使用您的 API Key 登入 Claude Code Hub 後台" + "privacyNote": "請使用您的 API Key 登入後台" }, "errors": { "loginFailed": "登錄失敗", @@ -38,6 +38,9 @@ "invalidToken": "無效的認證令牌", "tokenRequired": "需要提供認證令牌", "sessionExpired": "會話已過期,請重新登錄", - "unauthorized": "未授權,請先登錄" + "unauthorized": "未授權,請先登錄", + "apiKeyRequired": "請輸入 API Key", + "apiKeyInvalidOrExpired": "API Key 無效或已過期", + "serverError": "登錄失敗,請稍後重試" } } diff --git a/messages/zh-TW/common.json b/messages/zh-TW/common.json index f8fbf0173..63f549c18 100644 --- a/messages/zh-TW/common.json +++ b/messages/zh-TW/common.json @@ -48,5 +48,15 @@ "theme": "主題", "light": "淺色", "dark": "深色", - "system": "跟隨系統" + "system": "跟隨系統", + "relativeTimeShort": { + "now": "剛剛", + "secondsAgo": "{count}秒前", + "minutesAgo": "{count}分前", + "hoursAgo": "{count}時前", + "daysAgo": "{count}天前", + "weeksAgo": "{count}週前", + "monthsAgo": "{count}月前", + "yearsAgo": "{count}年前" + } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7fb6ace8b..d75750f14 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -717,7 +717,8 @@ "limit5hUsd": { "label": "5小時消費上限(USD)", "placeholder": "留空表示無限制", - "description": "5小時內最大消費金額" + "description": "5小時內最大消費金額", + "descriptionWithUserLimit": "5小時內最大消費金額(使用者上限:{limit})" }, "limitDailyUsd": { "label": "每日消費上限(USD)", @@ -743,17 +744,26 @@ "limitWeeklyUsd": { "label": "週消費上限(USD)", "placeholder": "留空表示無限制", - "description": "每週最大消費金額" + "description": "每週最大消費金額", + "descriptionWithUserLimit": "每週最大消費金額(使用者上限:{limit})" }, "limitMonthlyUsd": { "label": "月消費上限(USD)", "placeholder": "留空表示無限制", - "description": "每月最大消費金額" + "description": "每月最大消費金額", + "descriptionWithUserLimit": "每月最大消費金額(使用者上限:{limit})" + }, + "limitTotalUsd": { + "label": "總消費上限(USD)", + "placeholder": "留空表示無限制", + "description": "累計消費上限(不重置)", + "descriptionWithUserLimit": "不能超過使用者總限額({limit})" }, "limitConcurrentSessions": { "label": "並發 Session 上限", "placeholder": "0 表示無限制", - "description": "同時執行的對話數量" + "description": "同時執行的對話數量", + "descriptionWithUserLimit": "最大 Session 數(使用者上限:{limit})" }, "providerGroup": { "label": "供應商分組", @@ -762,6 +772,15 @@ "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商", "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})" }, + "cacheTtl": { + "label": "Cache TTL 覆寫", + "description": "強制為包含 cache_control 的請求設定 Anthropic prompt cache TTL。", + "options": { + "inherit": "不覆寫(跟隨供應商/客戶端)", + "5m": "5m", + "1h": "1h" + } + }, "successTitle": "金鑰建立成功", "successDescription": "您的 API 金鑰已成功建立。", "generatedKey": { @@ -1120,18 +1139,21 @@ "name": "金鑰名稱", "key": "金鑰", "group": "分組", - "todayUsage": "今日使用量", + "todayUsage": "今日請求", "todayCost": "今日花費", + "todayTokens": "今日Token", "lastUsed": "最後使用", "actions": "動作", "quotaButton": "查看限額用量", "fields": { - "callsLabel": "今日呼叫", + "callsLabel": "請求", + "tokensLabel": "Token", "costLabel": "今日消耗" } }, "expand": "展開", "collapse": "摺疊", + "refresh": "重新整理", "noKeys": "無金鑰", "defaultGroup": "default", "userStatus": { @@ -1183,6 +1205,10 @@ "currentExpiry": "目前到期時間", "neverExpires": "永不過期", "expired": "已過期", + "quickExtensionLabel": "快速延期", + "quickExtensionHint": "從目前到期日延長(若已過期則從現在開始)", + "customDateLabel": "設定到期日", + "customDateHint": "直接指定到期日期", "quickOptions": { "7days": "7天", "30days": "30天", @@ -1191,6 +1217,7 @@ }, "customDate": "自訂日期", "enableOnRenew": "同時啟用使用者", + "enableKeyOnRenew": "同時啟用金鑰", "cancel": "取消續期", "confirm": "確認續期", "confirming": "續期中...", @@ -1217,7 +1244,18 @@ "userEnabled": "使用者已啟用", "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", - "saving": "儲存中..." + "saving": "儲存中...", + "resetData": { + "title": "重置統計", + "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。", + "error": "重置資料失敗", + "button": "重置統計", + "confirmTitle": "重置所有統計?", + "confirmDescription": "這將永久刪除該使用者的所有請求日誌和使用統計。此操作無法撤銷。", + "confirm": "是的,重置全部", + "loading": "重置中...", + "success": "所有統計已重置" + } }, "batchEdit": { "enterMode": "批量編輯", diff --git a/messages/zh-TW/quota.json b/messages/zh-TW/quota.json index 501d1a6d5..8d2eb86c9 100644 --- a/messages/zh-TW/quota.json +++ b/messages/zh-TW/quota.json @@ -263,7 +263,8 @@ "limit5hUsd": { "label": "5小時消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "5小時內最大消費金額" + "description": "5小時內最大消費金額", + "descriptionWithUserLimit": "不能超過使用者5小時限額 ({limit})" }, "limitDailyUsd": { "label": "每日消費上限 (USD)", @@ -289,17 +290,26 @@ "limitWeeklyUsd": { "label": "週消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "每週最大消費金額" + "description": "每週最大消費金額", + "descriptionWithUserLimit": "不能超過使用者週限額 ({limit})" }, "limitMonthlyUsd": { "label": "月消費上限 (USD)", "placeholder": "留空表示無限制", - "description": "每月最大消費金額" + "description": "每月最大消費金額", + "descriptionWithUserLimit": "不能超過使用者月限額 ({limit})" + }, + "limitTotalUsd": { + "label": "總消費上限 (USD)", + "placeholder": "留空表示無限制", + "description": "累計消費上限(不重置)", + "descriptionWithUserLimit": "不能超過使用者總限額 ({limit})" }, "limitConcurrentSessions": { "label": "並發 Session 上限", "placeholder": "0 表示無限制", - "description": "同時運行的對話數量" + "description": "同時運行的對話數量", + "descriptionWithUserLimit": "不能超過使用者並發限額 ({limit})" }, "providerGroup": { "label": "供應商分組", diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index 38e186979..a974ee88f 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -28,11 +28,8 @@ export interface KeyQuotaUsageResult { export async function getKeyQuotaUsage(keyId: number): Promise> { try { - const session = await getSession(); + const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; - if (session.user.role !== "admin") { - return { ok: false, error: "Admin access required" }; - } const [keyRow] = await db .select() @@ -44,6 +41,11 @@ export async function getKeyQuotaUsage(keyId: number): Promise { const usageRecords = usageMap.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])); return { @@ -256,7 +261,8 @@ export async function getUsers(): Promise { minute: "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, lastUsedAt: stats?.lastUsedAt ?? null, lastProviderName: stats?.lastProviderName ?? null, @@ -473,7 +479,12 @@ export async function getUsersBatch( const usageRecords = usageMap.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])); return { @@ -517,7 +528,8 @@ export async function getUsersBatch( minute: "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, lastUsedAt: stats?.lastUsedAt ?? 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 }; } } + +/** + * 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 { + 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, + }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx index ab5f45392..af51746b2 100644 --- a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx @@ -70,14 +70,14 @@ export function AddKeyDialog({ return ( - + {generatedKey ? ( <> - + {t("successTitle")} {t("successDescription")} -
+
@@ -106,11 +106,11 @@ export function AddKeyDialog({

{t("generatedKey.hint")}

-
- -
+
+
+
) : ( diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index 8bc5a3e5d..be23ff43f 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -376,6 +376,8 @@ function BatchEditDialogInner({ if (anySuccess) { await queryClient.invalidateQueries({ queryKey: ["users"] }); + await queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + await queryClient.invalidateQueries({ queryKey: ["userTags"] }); } // Only close dialog and clear selection when fully successful diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index 884745ab8..ea543338f 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -52,7 +52,7 @@ export function EditKeyDialog({ return ( - + {t("title")} {t("description")} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index c3e2a4686..5c963c72c 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,14 +1,25 @@ "use client"; 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 { useTranslations } from "next-intl"; -import { useMemo, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; 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 { Dialog, DialogContent, @@ -18,6 +29,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { cn } from "@/lib/utils"; import { UpdateUserSchema } from "@/lib/validation/schemas"; import type { UserDisplay } from "@/types/user"; import { DangerZone } from "./forms/danger-zone"; @@ -71,6 +83,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); const [isPending, startTransition] = useTransition(); + const [isResettingAll, setIsResettingAll] = useState(false); + const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -110,6 +124,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] submit failed", error); @@ -161,6 +177,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userDisabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] disable user failed", error); @@ -178,6 +196,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userEnabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] enable user failed", error); @@ -194,9 +214,32 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); 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 (
@@ -243,6 +286,59 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr modelSuggestions={modelSuggestions} /> + {/* Reset Data Section - Admin Only */} +
+
+
+

+ {t("editDialog.resetData.title")} +

+

+ {t("editDialog.resetData.description")} +

+
+ + + + + + + + {t("editDialog.resetData.confirmTitle")} + + {t("editDialog.resetData.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetAllStatistics(); + }} + disabled={isResettingAll} + className={cn(buttonVariants({ variant: "destructive" }))} + > + {isResettingAll ? ( + <> + + {t("editDialog.resetData.loading")} + + ) : ( + t("editDialog.resetData.confirm") + )} + + + + +
+
+
- + -

- 强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。 -

+

{t("cacheTtl.description")}

diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index ac409840a..ba6bb2ab7 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -1,4 +1,5 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; 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 [providerGroupSuggestions, setProviderGroupSuggestions] = useState([]); const router = useRouter(); + const queryClient = useQueryClient(); const t = useTranslations("quota.keys.editKeyForm"); const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields"); const tBalancePage = useTranslations( @@ -128,6 +130,8 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK return; } toast.success(t("success")); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); onSuccess?.(); router.refresh(); } catch (err) { diff --git a/src/app/[locale]/dashboard/_components/user/key-list.tsx b/src/app/[locale]/dashboard/_components/user/key-list.tsx index ff2939920..089acb827 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list.tsx @@ -236,7 +236,7 @@ export function KeyList({ {record.lastUsedAt ? ( <>
- +
{record.lastProviderName && (
diff --git a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx index e2ed9e18b..24f752bc8 100644 --- a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx @@ -1,6 +1,16 @@ "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 { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; @@ -25,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils"; import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; import { formatDate } from "@/lib/utils/date-format"; +import { formatTokenAmount } from "@/lib/utils/token"; import { type QuickRenewKey, QuickRenewKeyDialog } from "./forms/quick-renew-key-dialog"; import { KeyFullDisplayDialog } from "./key-full-display-dialog"; import { KeyQuotaUsageDialog } from "./key-quota-usage-dialog"; @@ -40,6 +51,7 @@ export interface KeyRowItemProps { providerGroup?: string | null; todayUsage: number; todayCallCount: number; + todayTokens: number; lastUsedAt: Date | null; expiresAt: string; status: "enabled" | "disabled"; @@ -67,9 +79,11 @@ export interface KeyRowItemProps { group: string; todayUsage: string; todayCost: string; + todayTokens: string; lastUsed: string; actions: string; callsLabel: string; + tokensLabel: string; costLabel: string; }; actions: { @@ -180,7 +194,6 @@ export function KeyRowItem({ // 计算 key 过期状态 const keyExpiryStatus = getKeyExpiryStatus(localStatus, localExpiresAt); const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length); - const effectiveGroupText = effectiveGroups.join(", "); const canReveal = Boolean(keyData.fullKey); const canCopy = Boolean(keyData.canCopy && keyData.fullKey); @@ -302,8 +315,8 @@ export function KeyRowItem({ 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", 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" )} > @@ -398,17 +411,12 @@ export function KeyRowItem({ key={group} variant="outline" className="text-xs font-mono max-w-[120px] truncate" - title={group} > {group} ))} {remainingGroups > 0 ? ( - + +{remainingGroups} ) : null} @@ -421,9 +429,11 @@ export function KeyRowItem({
-

- {effectiveGroupText} -

+
    + {effectiveGroups.map((group) => ( +
  • {group}
  • + ))} +
@@ -434,23 +444,28 @@ export function KeyRowItem({ className="text-right tabular-nums flex items-center justify-end gap-1" title={translations.fields.todayUsage} > - {translations.fields.callsLabel}: + {Number(keyData.todayCallCount || 0).toLocaleString()} - {/* 今日消耗(成本) */} + {/* 今日Token数 */}
- {translations.fields.costLabel}: - {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)} + + {formatTokenAmount(keyData.todayTokens || 0)} +
+ + {/* 今日消耗(成本) */} +
+ {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}
{/* 最后使用 */}
{keyData.lastUsedAt ? ( - + ) : ( - )} diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 5be783d78..68a7ffb1f 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -1,7 +1,16 @@ "use client"; 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 { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -209,6 +218,8 @@ export function UserKeyTableRow({ return; } toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled")); + // Инвалидировать кэш React Query для всех фильтров + queryClient.invalidateQueries({ queryKey: ["users"] }); // 刷新服务端数据 router.refresh(); } catch (error) { @@ -267,30 +278,68 @@ export function UserKeyTableRow({ {isExpanded ? translations.collapse : translations.expand} + + + + {expiryStatus.label === "active" && ( + + )} + {expiryStatus.label === "disabled" && ( + + )} + {expiryStatus.label === "expiringSoon" && ( + + )} + {expiryStatus.label === "expired" && ( + + )} + + + {tUserStatus(expiryStatus.label)} + {user.name} - - {tUserStatus(expiryStatus.label)} - - {visibleGroups.map((group) => { - const bgColor = getGroupColor(group); - return ( - - {group} - - ); - })} - {remainingGroupsCount > 0 && ( - - +{remainingGroupsCount} - - )} + {userGroups.length > 0 ? ( + + +
+ {visibleGroups.map((group) => { + if (group.toLowerCase() === "default") { + return ( + + {group} + + ); + } + const bgColor = getGroupColor(group); + return ( + + {group} + + ); + })} + {remainingGroupsCount > 0 && ( + + +{remainingGroupsCount} + + )} +
+
+ +
    + {userGroups.map((group) => ( +
  • {group}
  • + ))} +
+
+
+ ) : null} {user.tags && user.tags.length > 0 && ( [{user.tags.join(", ")}] @@ -453,6 +502,7 @@ export function UserKeyTableRow({ providerGroup: key.providerGroup, todayUsage: key.todayUsage, todayCallCount: key.todayCallCount, + todayTokens: key.todayTokens, lastUsedAt: key.lastUsedAt, expiresAt: key.expiresAt, status: key.status, diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index 948f92349..f0e2b429e 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -1,7 +1,7 @@ "use client"; 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 { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -91,6 +91,8 @@ export interface UserManagementTableProps { failed: string; }; }; + onRefresh?: () => void; + isRefreshing?: boolean; } const USER_ROW_HEIGHT = 52; @@ -124,6 +126,8 @@ export function UserManagementTable({ onSelectKey, onOpenBatchEdit, translations, + onRefresh, + isRefreshing, }: UserManagementTableProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -422,6 +426,19 @@ export function UserManagementTable({ /> ) : null}
+ + {onRefresh ? ( + + ) : null}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 8a107d1cb..3d35c1e0d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -419,11 +419,11 @@ export function UsageLogsFilters({
setLocalFilters({ ...localFilters, - statusCode: value && value !== "!200" ? parseInt(value, 10) : undefined, + statusCode: + value && value !== "!200" && value !== "__all__" + ? parseInt(value, 10) + : undefined, excludeStatusCode200: value === "!200", }) } @@ -617,6 +623,7 @@ export function UsageLogsFilters({ + {t("logs.filters.allStatusCodes")} {t("logs.statusCodes.not200")} {t("logs.statusCodes.200")} {t("logs.statusCodes.400")} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index b620cfa3d..c02b907dc 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -124,7 +124,7 @@ export function UsageLogsTable({ >
- +
{log.userName} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 52f07e023..e38646a3d 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -270,7 +270,7 @@ export function VirtualizedLogsTable({ > {/* Time */}
- +
{/* User */} diff --git a/src/app/[locale]/dashboard/providers/page.tsx b/src/app/[locale]/dashboard/providers/page.tsx index dc5d2b2d8..250895d9f 100644 --- a/src/app/[locale]/dashboard/providers/page.tsx +++ b/src/app/[locale]/dashboard/providers/page.tsx @@ -1,5 +1,6 @@ import { BarChart3 } from "lucide-react"; 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 { SchedulingRulesDialog } from "@/app/[locale]/settings/providers/_components/scheduling-rules-dialog"; import { Section } from "@/components/section"; @@ -50,6 +51,7 @@ export default async function DashboardProvidersPage({ {t("providers.section.leaderboard")} + } diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index d7997a207..faa78d1f9 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -7,7 +7,7 @@ import { useQuery, useQueryClient, } 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 { useCallback, useEffect, useMemo, useState } from "react"; import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users"; @@ -67,6 +67,8 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { const tUserMgmt = useTranslations("dashboard.userManagement"); const tKeyList = useTranslations("dashboard.keyList"); const tCommon = useTranslations("common"); + const tProviderGroup = useTranslations("myUsage.providerGroup"); + const tRestrictions = useTranslations("myUsage.accessRestrictions"); const queryClient = useQueryClient(); const isAdmin = currentUser.role === "admin"; const [searchTerm, setSearchTerm] = useState(""); @@ -138,6 +140,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { isFetching, isError, error, + refetch, } = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam }) => { @@ -434,9 +437,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { group: tUserMgmt("table.keyRow.group"), todayUsage: tUserMgmt("table.keyRow.todayUsage"), todayCost: tUserMgmt("table.keyRow.todayCost"), + todayTokens: tUserMgmt("table.keyRow.todayTokens"), lastUsed: tUserMgmt("table.keyRow.lastUsed"), actions: tUserMgmt("table.keyRow.actions"), callsLabel: tUserMgmt("table.keyRow.fields.callsLabel"), + tokensLabel: tUserMgmt("table.keyRow.fields.tokensLabel"), costLabel: tUserMgmt("table.keyRow.fields.costLabel"), }, actions: { @@ -504,6 +509,55 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { )}
+ {/* Provider Group & Access Restrictions block (non-admin users only) */} + {!isAdmin && selfUser && ( +
+ {/* Provider Groups */} +
+
+ + {tProviderGroup("title")} +
+
+
+ + {tProviderGroup("userGroup")}: + + + {selfUser.providerGroup || tProviderGroup("allProviders")} + +
+
+
+ + {/* Access Restrictions */} +
+
+ + {tRestrictions("title")} +
+
+
+ {tRestrictions("models")}: + + {selfUser.allowedModels?.length + ? selfUser.allowedModels.join(", ") + : tRestrictions("noRestrictions")} + +
+
+ {tRestrictions("clients")}: + + {selfUser.allowedClients?.length + ? selfUser.allowedClients.join(", ") + : tRestrictions("noRestrictions")} + +
+
+
+
+ )} + {/* Toolbar with search and filters */}
{/* Search input */} @@ -628,7 +682,6 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
) : (
-
{isRefreshing ? : null}
refetch()} + isRefreshing={isRefreshing} />
)} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3bee8b20d..cc09adb16 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,21 +1,50 @@ 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 { logger } from "@/lib/logger"; // 需要数据库连接 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) { + const locale = getLocaleFromRequest(request); + try { + const t = await getTranslations({ locale, namespace: "auth.errors" }); const { key } = await request.json(); 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 }); if (!session) { - return NextResponse.json({ error: "API Key 无效或已过期" }, { status: 401 }); + return NextResponse.json({ error: t("apiKeyInvalidOrExpired") }, { status: 401 }); } // 设置认证 cookie @@ -35,6 +64,11 @@ export async function POST(request: NextRequest) { }); } catch (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 }); + } } } diff --git a/src/components/form/form-layout.tsx b/src/components/form/form-layout.tsx index 8c28325fd..6205df5e8 100644 --- a/src/components/form/form-layout.tsx +++ b/src/components/form/form-layout.tsx @@ -51,12 +51,12 @@ export function DialogFormLayout({ const t = useTranslations("forms"); return ( - + {config.title} {config.description && {config.description}} -
+
{children} @@ -68,7 +68,7 @@ export function DialogFormLayout({
- +