diff --git a/.gitignore b/.gitignore index e927f4fd6..49cd7850e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ docs-site/node_modules/ # local scratch tmp/ +.trae/ \ No newline at end of file diff --git a/messages/en/customs.json b/messages/en/customs.json index e46283833..c23d942b1 100644 --- a/messages/en/customs.json +++ b/messages/en/customs.json @@ -26,16 +26,27 @@ "viewRelease": "View Release" }, "metrics": { - "concurrent": "Concurrent", + "concurrent": "Active Sessions", "todayRequests": "Today's Requests", "todayCost": "Today's Cost", "avgResponse": "Average Response Time", - "viewDetails": "View Details" + "viewDetails": "View Details", + "rpm": "RPM", + "vsYesterday": "vs Yesterday" }, "activeSessions": { "title": "Active Sessions", "summary": "{count} sessions in the last {minutes} minutes", "empty": "No active sessions", - "viewAll": "View All" + "viewAll": "View All", + "loading": "Loading...", + "unknownUser": "unknown", + "status": { + "running": "RUNNING", + "init": "INIT", + "idle": "IDLE", + "error": "ERROR", + "done": "DONE" + } } } diff --git a/messages/en/settings/clientVersions.json b/messages/en/settings/clientVersions.json index 0822fc2cb..03ac3b4c0 100644 --- a/messages/en/settings/clientVersions.json +++ b/messages/en/settings/clientVersions.json @@ -38,7 +38,13 @@ "unknown": "Unknown", "user": "User", "usersCount": "{count} users", - "version": "Current Version" + "version": "Current Version", + "stats": { + "clientTypes": "Client Types", + "totalUsers": "Total Users", + "withGA": "With GA Version", + "coverage": "GA Coverage" + } }, "title": "Client Update Reminder", "toggle": { diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index 414b5daa4..db8bf1a03 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -37,6 +37,7 @@ import providersFormModelSelect from "./providers/form/modelSelect.json"; import providersFormName from "./providers/form/name.json"; import providersFormProviderTypes from "./providers/form/providerTypes.json"; import providersFormProxyTest from "./providers/form/proxyTest.json"; +import providersFormQuickPaste from "./providers/form/quickPaste.json"; import providersFormSections from "./providers/form/sections.json"; import providersFormStrings from "./providers/form/strings.json"; import providersFormSuccess from "./providers/form/success.json"; @@ -47,6 +48,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, @@ -60,6 +62,7 @@ const providersForm = { name: providersFormName, providerTypes: providersFormProviderTypes, proxyTest: providersFormProxyTest, + quickPaste: providersFormQuickPaste, sections: providersFormSections, success: providersFormSuccess, title: providersFormTitle, diff --git a/messages/en/settings/notifications.json b/messages/en/settings/notifications.json index 2ea6d7763..0df1994fe 100644 --- a/messages/en/settings/notifications.json +++ b/messages/en/settings/notifications.json @@ -69,6 +69,8 @@ "global": { "description": "Enable or disable all push notification features", "enable": "Enable Push Notifications", + "off": "Off", + "on": "On", "legacyModeDescription": "You are using legacy single-URL notifications. Create a push target to switch to multi-target mode.", "legacyModeTitle": "Legacy Mode", "title": "Notification Master Switch" diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json new file mode 100644 index 000000000..7abbd6045 --- /dev/null +++ b/messages/en/settings/providers/batchEdit.json @@ -0,0 +1,43 @@ +{ + "enterMode": "Batch Edit", + "exitMode": "Exit", + "selectAll": "Select All", + "invertSelection": "Invert", + "selectedCount": "{count} selected", + "editSelected": "Edit Selected", + "actions": { + "edit": "Edit", + "delete": "Delete", + "resetCircuit": "Reset Circuit" + }, + "dialog": { + "editTitle": "Batch Edit Providers", + "editDesc": "Changes will apply to {count} providers", + "deleteTitle": "Delete Providers", + "deleteDesc": "Permanently delete {count} providers?", + "resetCircuitTitle": "Reset Circuit Breakers", + "resetCircuitDesc": "Reset circuit breaker for {count} providers?", + "next": "Next", + "noFieldEnabled": "Please enable at least one field to update" + }, + "fields": { + "isEnabled": "Status", + "priority": "Priority", + "weight": "Weight", + "costMultiplier": "Cost Multiplier", + "groupTag": "Group Tag" + }, + "confirm": { + "title": "Confirm Operation", + "cancel": "Cancel", + "confirm": "Confirm", + "goBack": "Go Back", + "processing": "Processing..." + }, + "toast": { + "updated": "Updated {count} providers", + "deleted": "Deleted {count} providers", + "circuitReset": "Reset {count} circuit breakers", + "failed": "Operation failed: {error}" + } +} diff --git a/messages/en/settings/providers/form/common.json b/messages/en/settings/providers/form/common.json index 9b1cb78c9..36534463d 100644 --- a/messages/en/settings/providers/form/common.json +++ b/messages/en/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "Core" + "core": "Core", + "tabs": { + "basic": "Basic", + "routing": "Routing", + "limits": "Limits", + "network": "Network", + "testing": "Testing" + } } diff --git a/messages/en/settings/providers/form/key.json b/messages/en/settings/providers/form/key.json index 74e91988b..7764faf0b 100644 --- a/messages/en/settings/providers/form/key.json +++ b/messages/en/settings/providers/form/key.json @@ -1,6 +1,7 @@ { "currentKey": "Current key: {key}", "label": "API Key", + "labelEdit": "API Key (Leave empty to keep unchanged)", "leaveEmpty": "(Leave empty to keep unchanged)", "leaveEmptyDesc": "Leave empty to keep existing key", "placeholder": "Enter API Key" diff --git a/messages/en/settings/providers/form/name.json b/messages/en/settings/providers/form/name.json index c29e1b62f..5ef645a94 100644 --- a/messages/en/settings/providers/form/name.json +++ b/messages/en/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "Provider Name *", + "label": "Provider Name", "placeholder": "e.g. Zhipu" } diff --git a/messages/en/settings/providers/form/quickPaste.json b/messages/en/settings/providers/form/quickPaste.json new file mode 100644 index 000000000..b1c68b99c --- /dev/null +++ b/messages/en/settings/providers/form/quickPaste.json @@ -0,0 +1,15 @@ +{ + "button": "Quick Paste", + "title": "Quick Paste Provider Info", + "description": "Paste text containing provider details (URL, API key, etc.) and we'll extract the information automatically.", + "placeholder": "Paste provider configuration text here...", + "preview": "Detected Information", + "type": "Type", + "name": "Name", + "url": "URL", + "key": "API Key", + "notFound": "Not detected", + "parseError": "Could not parse provider information from the text", + "cancel": "Cancel", + "confirm": "Apply" +} diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index c3f6ad646..62dfad529 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "Provider Identity", + "desc": "Set a unique name to identify this provider" + }, + "endpoint": { + "title": "API Endpoint", + "desc": "Configure the base URL for API requests" + }, + "auth": { + "title": "Authentication", + "desc": "Provide your API key for authentication" + } + }, "apiTest": { "desc": "Validate whether the selected provider type and model respond correctly. Defaults to the routing configuration unless overridden.", "summary": "Verify provider & model connectivity", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "Number of consecutive failures to trigger break", "label": "Failure Threshold", - "placeholder": "5" + "placeholder": "5", + "warning": "Setting to 0 disables the circuit breaker - use with caution" }, "maxRetryAttempts": { "desc": "Total tries (including the first call) before switching providers. Leave empty to use the system default.", @@ -129,6 +144,7 @@ "dailyResetTime": { "label": "Daily Reset Time (HH:mm)" }, + "desc": "Configure spending limits to control costs across different time windows", "limit5h": { "label": "5h Spend Limit (USD)", "placeholder": "Leave empty for unlimited" @@ -164,6 +180,11 @@ }, "title": "Rate Limit" }, + "limits": { + "timeBased": "Time-based Limits", + "dailyReset": "Daily Reset Settings", + "otherLimits": "Other Limits" + }, "routing": { "cacheTtl": { "desc": "Force prompt cache TTL; only affects requests with cache_control.", diff --git a/messages/en/settings/providers/form/url.json b/messages/en/settings/providers/form/url.json index 7b3c8a6ed..70f635779 100644 --- a/messages/en/settings/providers/form/url.json +++ b/messages/en/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API Address *", + "label": "API Address", "placeholder": "e.g. https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/ja/customs.json b/messages/ja/customs.json index 392be68fa..72a1a396d 100644 --- a/messages/ja/customs.json +++ b/messages/ja/customs.json @@ -26,16 +26,27 @@ "viewRelease": "リリースを表示" }, "metrics": { - "concurrent": "同時実行", + "concurrent": "アクティブセッション数", "todayRequests": "本日のリクエスト", "todayCost": "本日のコスト", "avgResponse": "平均応答時間", - "viewDetails": "詳細を表示" + "viewDetails": "詳細を表示", + "rpm": "RPM", + "vsYesterday": "前日同時間帯比" }, "activeSessions": { "title": "アクティブなセッション", "summary": "過去{minutes}分間の{count}個のセッション", "empty": "アクティブなセッションがありません", - "viewAll": "すべて表示" + "viewAll": "すべて表示", + "loading": "読み込み中...", + "unknownUser": "不明", + "status": { + "running": "実行中", + "init": "初期化", + "idle": "アイドル", + "error": "エラー", + "done": "完了" + } } } diff --git a/messages/ja/settings/clientVersions.json b/messages/ja/settings/clientVersions.json index 65cafc17b..410d8b81d 100644 --- a/messages/ja/settings/clientVersions.json +++ b/messages/ja/settings/clientVersions.json @@ -38,7 +38,13 @@ "unknown": "不明", "user": "ユーザー", "usersCount": "{count}名のユーザー", - "version": "現在のバージョン" + "version": "現在のバージョン", + "stats": { + "clientTypes": "クライアントタイプ", + "totalUsers": "ユーザー総数", + "withGA": "GA版あり", + "coverage": "GAカバレッジ" + } }, "title": "クライアント更新リマインダー", "toggle": { diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index 414b5daa4..db8bf1a03 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -37,6 +37,7 @@ import providersFormModelSelect from "./providers/form/modelSelect.json"; import providersFormName from "./providers/form/name.json"; import providersFormProviderTypes from "./providers/form/providerTypes.json"; import providersFormProxyTest from "./providers/form/proxyTest.json"; +import providersFormQuickPaste from "./providers/form/quickPaste.json"; import providersFormSections from "./providers/form/sections.json"; import providersFormStrings from "./providers/form/strings.json"; import providersFormSuccess from "./providers/form/success.json"; @@ -47,6 +48,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, @@ -60,6 +62,7 @@ const providersForm = { name: providersFormName, providerTypes: providersFormProviderTypes, proxyTest: providersFormProxyTest, + quickPaste: providersFormQuickPaste, sections: providersFormSections, success: providersFormSuccess, title: providersFormTitle, diff --git a/messages/ja/settings/notifications.json b/messages/ja/settings/notifications.json index 3827a52e7..561a8c73e 100644 --- a/messages/ja/settings/notifications.json +++ b/messages/ja/settings/notifications.json @@ -69,6 +69,8 @@ "global": { "description": "すべてのプッシュ通知機能を有効または無効にする", "enable": "プッシュ通知を有効にする", + "off": "オフ", + "on": "オン", "legacyModeDescription": "現在は旧来の単一URL通知設定を使用しています。プッシュ先を作成するとマルチターゲットモードに切り替わります。", "legacyModeTitle": "互換モード", "title": "通知マスタースイッチ" diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json new file mode 100644 index 000000000..68f98a0a2 --- /dev/null +++ b/messages/ja/settings/providers/batchEdit.json @@ -0,0 +1,43 @@ +{ + "enterMode": "一括編集", + "exitMode": "終了", + "selectAll": "全選択", + "invertSelection": "反転", + "selectedCount": "{count} 件選択中", + "editSelected": "選択項目を編集", + "actions": { + "edit": "編集", + "delete": "削除", + "resetCircuit": "サーキット リセット" + }, + "dialog": { + "editTitle": "プロバイダーの一括編集", + "editDesc": "{count} 件のプロバイダーに変更が適用されます", + "deleteTitle": "プロバイダーの削除", + "deleteDesc": "{count} 件のプロバイダーを完全に削除しますか?", + "resetCircuitTitle": "サーキットブレーカーのリセット", + "resetCircuitDesc": "{count} 件のプロバイダーのサーキットブレーカーをリセットしますか?", + "next": "次へ", + "noFieldEnabled": "更新するフィールドを少なくとも1つ有効にしてください" + }, + "fields": { + "isEnabled": "ステータス", + "priority": "優先度", + "weight": "重み", + "costMultiplier": "価格倍率", + "groupTag": "グループタグ" + }, + "confirm": { + "title": "操作の確認", + "cancel": "キャンセル", + "confirm": "確認", + "goBack": "戻る", + "processing": "処理中..." + }, + "toast": { + "updated": "{count} 件のプロバイダーを更新しました", + "deleted": "{count} 件のプロバイダーを削除しました", + "circuitReset": "{count} 件のサーキットブレーカーをリセットしました", + "failed": "操作に失敗しました: {error}" + } +} diff --git a/messages/ja/settings/providers/form/common.json b/messages/ja/settings/providers/form/common.json index 0eefe2718..d1cb1da2c 100644 --- a/messages/ja/settings/providers/form/common.json +++ b/messages/ja/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "コア" + "core": "コア", + "tabs": { + "basic": "基本情報", + "routing": "ルーティング", + "limits": "制限", + "network": "ネットワーク", + "testing": "テスト" + } } diff --git a/messages/ja/settings/providers/form/key.json b/messages/ja/settings/providers/form/key.json index 1ad44a060..f40d9137a 100644 --- a/messages/ja/settings/providers/form/key.json +++ b/messages/ja/settings/providers/form/key.json @@ -1,6 +1,7 @@ { "currentKey": "現在のキー: {key}", "label": "API キー", + "labelEdit": "API キー(空欄のままにすると変更しません)", "leaveEmpty": "(空欄のままにすると変更しません)", "leaveEmptyDesc": "空欄のままにすると既存のキーを保持します", "placeholder": "API キーを入力" diff --git a/messages/ja/settings/providers/form/name.json b/messages/ja/settings/providers/form/name.json index 3e98216b7..ae0cbc008 100644 --- a/messages/ja/settings/providers/form/name.json +++ b/messages/ja/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "プロバイダー名 *", + "label": "プロバイダー名", "placeholder": "例: Zhipu" } diff --git a/messages/ja/settings/providers/form/quickPaste.json b/messages/ja/settings/providers/form/quickPaste.json new file mode 100644 index 000000000..be75d346b --- /dev/null +++ b/messages/ja/settings/providers/form/quickPaste.json @@ -0,0 +1,15 @@ +{ + "button": "クイック貼り付け", + "title": "クイック貼り付け", + "description": "プロバイダー情報(URL、APIキーなど)を含むテキストを貼り付けると、自動的に抽出されます。", + "placeholder": "ここにプロバイダー設定テキストを貼り付けてください...", + "preview": "検出された情報", + "type": "タイプ", + "name": "名前", + "url": "URL", + "key": "APIキー", + "notFound": "検出されませんでした", + "parseError": "テキストからプロバイダー情報を解析できませんでした", + "cancel": "キャンセル", + "confirm": "適用" +} diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 40edf78d4..84c69e2bf 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "プロバイダー識別", + "desc": "このプロバイダーを識別する一意の名前を設定" + }, + "endpoint": { + "title": "API エンドポイント", + "desc": "API リクエストのベース URL を設定" + }, + "auth": { + "title": "認証", + "desc": "認証用の API キーを入力" + } + }, "apiTest": { "desc": "プロバイダーのモデルが利用可能かをテストします。既定ではルーティング設定で選択したプロバイダー種別に従います。", "summary": "プロバイダーとモデルの接続性を確認", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "何回連続失敗でブレークするか", "label": "失敗しきい値(回)", - "placeholder": "5" + "placeholder": "5", + "warning": "0に設定するとサーキットブレーカーが無効になります - 慎重に使用してください" }, "maxRetryAttempts": { "desc": "初回呼び出しを含め、同一プロバイダーで試行する上限。超えると他のプロバイダーへ切り替えます。未入力の場合はデフォルト値を使用。", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "レート制限", + "desc": "異なる時間枠での消費制限を設定してコストを管理します", "dailyResetMode": { "desc": { "fixed": "毎日決まった時刻にクォータをリセット", @@ -164,6 +181,11 @@ }, "title": "レート制限" }, + "limits": { + "timeBased": "時間ベースの制限", + "dailyReset": "日次リセット設定", + "otherLimits": "その他の制限" + }, "routing": { "cacheTtl": { "desc": "プロンプトキャッシュのTTLを強制設定。cache_controlを含むリクエストにのみ適用されます。", diff --git a/messages/ja/settings/providers/form/url.json b/messages/ja/settings/providers/form/url.json index 7285ae182..df74080f9 100644 --- a/messages/ja/settings/providers/form/url.json +++ b/messages/ja/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API アドレス *", + "label": "API アドレス", "placeholder": "例: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/ru/customs.json b/messages/ru/customs.json index 16284146a..aa7fad617 100644 --- a/messages/ru/customs.json +++ b/messages/ru/customs.json @@ -26,16 +26,27 @@ "viewRelease": "Просмотр релиза" }, "metrics": { - "concurrent": "Одновременно", + "concurrent": "Активные сессии", "todayRequests": "Запросы сегодня", "todayCost": "Стоимость сегодня", "avgResponse": "Среднее время ответа", - "viewDetails": "Просмотр деталей" + "viewDetails": "Просмотр деталей", + "rpm": "RPM", + "vsYesterday": "к вчера" }, "activeSessions": { "title": "Активные сеансы", "summary": "{count} сеансов в последние {minutes} минут", "empty": "Нет активных сеансов", - "viewAll": "Просмотреть все" + "viewAll": "Просмотреть все", + "loading": "Загрузка...", + "unknownUser": "неизвестно", + "status": { + "running": "РАБОТАЕТ", + "init": "ИНИЦИАЛИЗАЦИЯ", + "idle": "ОЖИДАНИЕ", + "error": "ОШИБКА", + "done": "ЗАВЕРШЕНО" + } } } diff --git a/messages/ru/settings/clientVersions.json b/messages/ru/settings/clientVersions.json index 98166ed4a..f274d2e3c 100644 --- a/messages/ru/settings/clientVersions.json +++ b/messages/ru/settings/clientVersions.json @@ -38,7 +38,13 @@ "unknown": "Неизвестно", "user": "Пользователь", "usersCount": "{count} пользователей", - "version": "Текущая версия" + "version": "Текущая версия", + "stats": { + "clientTypes": "Типы клиентов", + "totalUsers": "Всего пользователей", + "withGA": "С GA версией", + "coverage": "Покрытие GA" + } }, "title": "Напоминание об обновлении клиента", "toggle": { diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index 414b5daa4..db8bf1a03 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -37,6 +37,7 @@ import providersFormModelSelect from "./providers/form/modelSelect.json"; import providersFormName from "./providers/form/name.json"; import providersFormProviderTypes from "./providers/form/providerTypes.json"; import providersFormProxyTest from "./providers/form/proxyTest.json"; +import providersFormQuickPaste from "./providers/form/quickPaste.json"; import providersFormSections from "./providers/form/sections.json"; import providersFormStrings from "./providers/form/strings.json"; import providersFormSuccess from "./providers/form/success.json"; @@ -47,6 +48,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, @@ -60,6 +62,7 @@ const providersForm = { name: providersFormName, providerTypes: providersFormProviderTypes, proxyTest: providersFormProxyTest, + quickPaste: providersFormQuickPaste, sections: providersFormSections, success: providersFormSuccess, title: providersFormTitle, diff --git a/messages/ru/settings/notifications.json b/messages/ru/settings/notifications.json index 7809e0136..c7977e111 100644 --- a/messages/ru/settings/notifications.json +++ b/messages/ru/settings/notifications.json @@ -69,6 +69,8 @@ "global": { "description": "Включить или отключить все функции push-уведомлений", "enable": "Включить push-уведомления", + "off": "Выкл", + "on": "Вкл", "legacyModeDescription": "Сейчас используется устаревшая схема уведомлений с одним URL. Создайте цель отправки, чтобы перейти на режим с несколькими целями.", "legacyModeTitle": "Режим совместимости", "title": "Главный переключатель уведомлений" diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json new file mode 100644 index 000000000..3d5c6c4f3 --- /dev/null +++ b/messages/ru/settings/providers/batchEdit.json @@ -0,0 +1,43 @@ +{ + "enterMode": "Массовое редактирование", + "exitMode": "Выход", + "selectAll": "Выбрать все", + "invertSelection": "Инвертировать", + "selectedCount": "Выбрано: {count}", + "editSelected": "Редактировать выбранные", + "actions": { + "edit": "Редактировать", + "delete": "Удалить", + "resetCircuit": "Сбросить прерыватель" + }, + "dialog": { + "editTitle": "Массовое редактирование поставщиков", + "editDesc": "Изменения будут применены к {count} поставщикам", + "deleteTitle": "Удалить поставщиков", + "deleteDesc": "Удалить {count} поставщиков навсегда?", + "resetCircuitTitle": "Сбросить прерыватели", + "resetCircuitDesc": "Сбросить прерыватель для {count} поставщиков?", + "next": "Далее", + "noFieldEnabled": "Пожалуйста, включите хотя бы одно поле для обновления" + }, + "fields": { + "isEnabled": "Статус", + "priority": "Приоритет", + "weight": "Вес", + "costMultiplier": "Множитель стоимости", + "groupTag": "Тег группы" + }, + "confirm": { + "title": "Подтвердите операцию", + "cancel": "Отмена", + "confirm": "Подтвердить", + "goBack": "Назад", + "processing": "Обработка..." + }, + "toast": { + "updated": "Обновлено поставщиков: {count}", + "deleted": "Удалено поставщиков: {count}", + "circuitReset": "Сброшено прерывателей: {count}", + "failed": "Операция не удалась: {error}" + } +} diff --git a/messages/ru/settings/providers/form/common.json b/messages/ru/settings/providers/form/common.json index fd4722431..fadb3569f 100644 --- a/messages/ru/settings/providers/form/common.json +++ b/messages/ru/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "Основная" + "core": "Основная", + "tabs": { + "basic": "Основные", + "routing": "Маршрутизация", + "limits": "Лимиты", + "network": "Сеть", + "testing": "Тестирование" + } } diff --git a/messages/ru/settings/providers/form/key.json b/messages/ru/settings/providers/form/key.json index 719e8fb39..7bb499d2e 100644 --- a/messages/ru/settings/providers/form/key.json +++ b/messages/ru/settings/providers/form/key.json @@ -1,6 +1,7 @@ { "currentKey": "Текущий ключ: {key}", "label": "API ключ", + "labelEdit": "API ключ (Оставьте пустым, чтобы не менять)", "leaveEmpty": "(Оставьте пустым, чтобы не менять)", "leaveEmptyDesc": "Пустое значение — без изменений", "placeholder": "Введите API ключ" diff --git a/messages/ru/settings/providers/form/name.json b/messages/ru/settings/providers/form/name.json index 1140a7325..8cf655824 100644 --- a/messages/ru/settings/providers/form/name.json +++ b/messages/ru/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "Имя провайдера *", + "label": "Имя провайдера", "placeholder": "например: Zhipu" } diff --git a/messages/ru/settings/providers/form/quickPaste.json b/messages/ru/settings/providers/form/quickPaste.json new file mode 100644 index 000000000..d0c27d1ab --- /dev/null +++ b/messages/ru/settings/providers/form/quickPaste.json @@ -0,0 +1,15 @@ +{ + "button": "Быстрая вставка", + "title": "Быстрая вставка", + "description": "Вставьте текст с информацией о провайдере (URL, API-ключ и т.д.) для автоматического извлечения.", + "placeholder": "Вставьте текст конфигурации провайдера здесь...", + "preview": "Обнаруженная информация", + "type": "Тип", + "name": "Название", + "url": "URL", + "key": "API-ключ", + "notFound": "Не обнаружено", + "parseError": "Не удалось извлечь информацию о провайдере из текста", + "cancel": "Отмена", + "confirm": "Применить" +} diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 6da02eba6..afea91597 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "Идентификация провайдера", + "desc": "Укажите уникальное имя для идентификации этого провайдера" + }, + "endpoint": { + "title": "API Endpoint", + "desc": "Настройте базовый URL для API запросов" + }, + "auth": { + "title": "Аутентификация", + "desc": "Укажите API ключ для аутентификации" + } + }, "apiTest": { "desc": "Проверяет доступность модели у провайдера. По умолчанию соответствует типу провайдера, выбранному в настройках маршрутизации.", "summary": "Проверка связности провайдера и модели", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "Сколько подряд неудач для срабатывания", "label": "Порог неудач", - "placeholder": "5" + "placeholder": "5", + "warning": "Значение 0 отключает предохранитель - используйте с осторожностью" }, "maxRetryAttempts": { "desc": "Общее число попыток (включая первую) перед переключением на другого провайдера. Оставьте пустым для значения по умолчанию.", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "Ограничения", + "desc": "Настройка лимитов расходов для контроля затрат в разных временных окнах", "dailyResetMode": { "desc": { "fixed": "Сбрасывать квоту каждый день в фиксированное время", @@ -164,6 +181,11 @@ }, "title": "Ограничения" }, + "limits": { + "timeBased": "Временные ограничения", + "dailyReset": "Настройки ежедневного сброса", + "otherLimits": "Другие ограничения" + }, "routing": { "cacheTtl": { "desc": "Принудительно задать TTL кэша промптов; влияет только на запросы с cache_control.", diff --git a/messages/ru/settings/providers/form/url.json b/messages/ru/settings/providers/form/url.json index 8d0a5fcbe..43e85de68 100644 --- a/messages/ru/settings/providers/form/url.json +++ b/messages/ru/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "Адрес API *", + "label": "Адрес API", "placeholder": "например: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/zh-CN/customs.json b/messages/zh-CN/customs.json index 037ec5784..f035820bf 100644 --- a/messages/zh-CN/customs.json +++ b/messages/zh-CN/customs.json @@ -26,16 +26,27 @@ "viewRelease": "查看发布" }, "metrics": { - "concurrent": "并发数", + "concurrent": "活跃 Session 数", "todayRequests": "今日请求", "todayCost": "今日消费", "avgResponse": "平均响应时间", - "viewDetails": "查看详情" + "viewDetails": "查看详情", + "rpm": "RPM", + "vsYesterday": "较昨日同期" }, "activeSessions": { "title": "活跃 Session", "summary": "{count} 个 Session,{minutes} 分钟内", "empty": "暂无活跃 Session", - "viewAll": "查看全部" + "viewAll": "查看全部", + "loading": "加载中...", + "unknownUser": "未知", + "status": { + "running": "运行中", + "init": "初始化", + "idle": "空闲", + "error": "错误", + "done": "完成" + } } } diff --git a/messages/zh-CN/settings/clientVersions.json b/messages/zh-CN/settings/clientVersions.json index b13c0e3cb..25f1bea5e 100644 --- a/messages/zh-CN/settings/clientVersions.json +++ b/messages/zh-CN/settings/clientVersions.json @@ -46,6 +46,12 @@ "noUsers": "暂无用户数据", "latest": "最新", "needsUpgrade": "需升级", - "unknown": "未知" + "unknown": "未知", + "stats": { + "clientTypes": "客户端类型", + "totalUsers": "用户总数", + "withGA": "有 GA 版本", + "coverage": "GA 覆盖率" + } } } diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index 414b5daa4..db8bf1a03 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -37,6 +37,7 @@ import providersFormModelSelect from "./providers/form/modelSelect.json"; import providersFormName from "./providers/form/name.json"; import providersFormProviderTypes from "./providers/form/providerTypes.json"; import providersFormProxyTest from "./providers/form/proxyTest.json"; +import providersFormQuickPaste from "./providers/form/quickPaste.json"; import providersFormSections from "./providers/form/sections.json"; import providersFormStrings from "./providers/form/strings.json"; import providersFormSuccess from "./providers/form/success.json"; @@ -47,6 +48,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, @@ -60,6 +62,7 @@ const providersForm = { name: providersFormName, providerTypes: providersFormProviderTypes, proxyTest: providersFormProxyTest, + quickPaste: providersFormQuickPaste, sections: providersFormSections, success: providersFormSuccess, title: providersFormTitle, diff --git a/messages/zh-CN/settings/notifications.json b/messages/zh-CN/settings/notifications.json index 33f388fe8..26c893356 100644 --- a/messages/zh-CN/settings/notifications.json +++ b/messages/zh-CN/settings/notifications.json @@ -5,6 +5,8 @@ "title": "通知总开关", "description": "启用或禁用所有消息推送功能", "enable": "启用消息推送", + "on": "已开启", + "off": "已关闭", "legacyModeTitle": "兼容模式", "legacyModeDescription": "当前使用旧版单 URL 推送配置。创建推送目标后将自动切换到多目标模式。" }, diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json new file mode 100644 index 000000000..87e6d842b --- /dev/null +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -0,0 +1,43 @@ +{ + "enterMode": "批量编辑", + "exitMode": "退出", + "selectAll": "全选", + "invertSelection": "反选", + "selectedCount": "已选 {count} 项", + "editSelected": "编辑选中项", + "actions": { + "edit": "编辑", + "delete": "删除", + "resetCircuit": "重置熔断" + }, + "dialog": { + "editTitle": "批量编辑供应商", + "editDesc": "修改将应用于 {count} 个供应商", + "deleteTitle": "删除供应商", + "deleteDesc": "确定永久删除 {count} 个供应商?", + "resetCircuitTitle": "重置熔断器", + "resetCircuitDesc": "确定重置 {count} 个供应商的熔断器?", + "next": "下一步", + "noFieldEnabled": "请至少启用一个要更新的字段" + }, + "fields": { + "isEnabled": "状态", + "priority": "优先级", + "weight": "权重", + "costMultiplier": "价格倍率", + "groupTag": "分组标签" + }, + "confirm": { + "title": "确认操作", + "cancel": "取消", + "confirm": "确认", + "goBack": "返回", + "processing": "处理中..." + }, + "toast": { + "updated": "已更新 {count} 个供应商", + "deleted": "已删除 {count} 个供应商", + "circuitReset": "已重置 {count} 个熔断器", + "failed": "操作失败: {error}" + } +} diff --git a/messages/zh-CN/settings/providers/form/common.json b/messages/zh-CN/settings/providers/form/common.json index c394b7c11..88808dc1f 100644 --- a/messages/zh-CN/settings/providers/form/common.json +++ b/messages/zh-CN/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "核心" + "core": "核心", + "tabs": { + "basic": "基础信息", + "routing": "路由", + "limits": "限制", + "network": "网络", + "testing": "测试" + } } diff --git a/messages/zh-CN/settings/providers/form/key.json b/messages/zh-CN/settings/providers/form/key.json index bcb728144..4658c4ca9 100644 --- a/messages/zh-CN/settings/providers/form/key.json +++ b/messages/zh-CN/settings/providers/form/key.json @@ -1,5 +1,6 @@ { "label": "API 密钥", + "labelEdit": "API 密钥(留空不更改)", "leaveEmpty": "(留空不更改)", "placeholder": "输入 API 密钥", "leaveEmptyDesc": "留空则不更改密钥", diff --git a/messages/zh-CN/settings/providers/form/name.json b/messages/zh-CN/settings/providers/form/name.json index 5d8ff44e6..1127edd0a 100644 --- a/messages/zh-CN/settings/providers/form/name.json +++ b/messages/zh-CN/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "服务商名称 *", + "label": "服务商名称", "placeholder": "例如: 智谱" } diff --git a/messages/zh-CN/settings/providers/form/quickPaste.json b/messages/zh-CN/settings/providers/form/quickPaste.json new file mode 100644 index 000000000..7d6cc2c6b --- /dev/null +++ b/messages/zh-CN/settings/providers/form/quickPaste.json @@ -0,0 +1,15 @@ +{ + "button": "快速粘贴", + "title": "快速粘贴", + "description": "粘贴包含供应商信息的文本(URL、API 密钥等),系统将自动提取。", + "placeholder": "在此粘贴供应商配置文本...", + "preview": "检测到的信息", + "type": "类型", + "name": "名称", + "url": "URL", + "key": "API 密钥", + "notFound": "未检测到", + "parseError": "无法从文本中解析供应商信息", + "cancel": "取消", + "confirm": "应用" +} diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index 709ee56ec..831e8463a 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "供应商身份", + "desc": "设置唯一名称以标识此供应商" + }, + "endpoint": { + "title": "API 端点", + "desc": "配置 API 请求的基础 URL" + }, + "auth": { + "title": "身份认证", + "desc": "提供用于认证的 API 密钥" + } + }, "routing": { "title": "路由配置", "summary": { @@ -123,6 +137,7 @@ }, "rateLimit": { "title": "限流配置", + "desc": "配置不同时间窗口的消费限制以控制成本", "summary": { "fiveHour": "5h: {amount}", "daily": "日: {amount} (重置 {resetTime})", @@ -171,6 +186,11 @@ "placeholder": "0 表示无限制" } }, + "limits": { + "timeBased": "时间维度限制", + "dailyReset": "每日重置设置", + "otherLimits": "其他限制" + }, "circuitBreaker": { "title": "熔断器配置", "summary": "{failureThreshold} 次失败 / {openDuration} 分钟熔断 / {successThreshold} 次成功恢复 / 每个供应商最多 {maxRetryAttempts} 次尝试", @@ -178,7 +198,8 @@ "failureThreshold": { "label": "失败阈值(次)", "placeholder": "5", - "desc": "连续失败多少次后触发熔断" + "desc": "连续失败多少次后触发熔断", + "warning": "设为 0 将禁用熔断器 - 请谨慎使用" }, "openDuration": { "label": "熔断时长(分钟)", diff --git a/messages/zh-CN/settings/providers/form/url.json b/messages/zh-CN/settings/providers/form/url.json index 338c3eb4e..25e0bf334 100644 --- a/messages/zh-CN/settings/providers/form/url.json +++ b/messages/zh-CN/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API 地址 *", + "label": "API 地址", "placeholder": "例如: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/zh-TW/customs.json b/messages/zh-TW/customs.json index 1d1ad9de0..e0e468de7 100644 --- a/messages/zh-TW/customs.json +++ b/messages/zh-TW/customs.json @@ -26,16 +26,27 @@ "viewRelease": "查看發佈" }, "metrics": { - "concurrent": "並發數", + "concurrent": "活躍 Session 數", "todayRequests": "今日請求", "todayCost": "今日消費", "avgResponse": "平均回應時間", - "viewDetails": "查看詳情" + "viewDetails": "查看詳情", + "rpm": "RPM", + "vsYesterday": "較昨日同期" }, "activeSessions": { "title": "活躍 Session", "summary": "{count} 個 Session,{minutes} 分鐘內", "empty": "暫無活躍 Session", - "viewAll": "查看全部" + "viewAll": "查看全部", + "loading": "載入中...", + "unknownUser": "未知", + "status": { + "running": "執行中", + "init": "初始化", + "idle": "閒置", + "error": "錯誤", + "done": "完成" + } } } diff --git a/messages/zh-TW/settings/clientVersions.json b/messages/zh-TW/settings/clientVersions.json index b774b0161..47bb4ed60 100644 --- a/messages/zh-TW/settings/clientVersions.json +++ b/messages/zh-TW/settings/clientVersions.json @@ -38,7 +38,13 @@ "unknown": "不明", "user": "用戶", "usersCount": "{count} 位用戶", - "version": "目前版本" + "version": "目前版本", + "stats": { + "clientTypes": "用戶端類型", + "totalUsers": "用戶總數", + "withGA": "有 GA 版本", + "coverage": "GA 覆蓋率" + } }, "title": "用戶端升級提醒", "toggle": { diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index 414b5daa4..db8bf1a03 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -37,6 +37,7 @@ import providersFormModelSelect from "./providers/form/modelSelect.json"; import providersFormName from "./providers/form/name.json"; import providersFormProviderTypes from "./providers/form/providerTypes.json"; import providersFormProxyTest from "./providers/form/proxyTest.json"; +import providersFormQuickPaste from "./providers/form/quickPaste.json"; import providersFormSections from "./providers/form/sections.json"; import providersFormStrings from "./providers/form/strings.json"; import providersFormSuccess from "./providers/form/success.json"; @@ -47,6 +48,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, @@ -60,6 +62,7 @@ const providersForm = { name: providersFormName, providerTypes: providersFormProviderTypes, proxyTest: providersFormProxyTest, + quickPaste: providersFormQuickPaste, sections: providersFormSections, success: providersFormSuccess, title: providersFormTitle, diff --git a/messages/zh-TW/settings/notifications.json b/messages/zh-TW/settings/notifications.json index f0096303f..5bca32fa3 100644 --- a/messages/zh-TW/settings/notifications.json +++ b/messages/zh-TW/settings/notifications.json @@ -69,6 +69,8 @@ "global": { "description": "啟用或停用所有訊息推送功能", "enable": "啟用訊息推送", + "off": "已關閉", + "on": "已開啟", "legacyModeDescription": "目前使用舊版單一 URL 推送設定。建立推送目標後將自動切換為多目標模式。", "legacyModeTitle": "相容模式", "title": "通知總開關" diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json new file mode 100644 index 000000000..30ac0472a --- /dev/null +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -0,0 +1,43 @@ +{ + "enterMode": "批次編輯", + "exitMode": "退出", + "selectAll": "全選", + "invertSelection": "反選", + "selectedCount": "已選 {count} 項", + "editSelected": "編輯選中項", + "actions": { + "edit": "編輯", + "delete": "刪除", + "resetCircuit": "重置熔斷" + }, + "dialog": { + "editTitle": "批次編輯供應商", + "editDesc": "修改將應用於 {count} 個供應商", + "deleteTitle": "刪除供應商", + "deleteDesc": "確定永久刪除 {count} 個供應商?", + "resetCircuitTitle": "重置熔斷器", + "resetCircuitDesc": "確定重置 {count} 個供應商的熔斷器?", + "next": "下一步", + "noFieldEnabled": "請至少啟用一個要更新的欄位" + }, + "fields": { + "isEnabled": "狀態", + "priority": "優先級", + "weight": "權重", + "costMultiplier": "價格倍率", + "groupTag": "分組標籤" + }, + "confirm": { + "title": "確認操作", + "cancel": "取消", + "confirm": "確認", + "goBack": "返回", + "processing": "處理中..." + }, + "toast": { + "updated": "已更新 {count} 個供應商", + "deleted": "已刪除 {count} 個供應商", + "circuitReset": "已重置 {count} 個熔斷器", + "failed": "操作失敗: {error}" + } +} diff --git a/messages/zh-TW/settings/providers/form/common.json b/messages/zh-TW/settings/providers/form/common.json index 56671c4d8..89489e20e 100644 --- a/messages/zh-TW/settings/providers/form/common.json +++ b/messages/zh-TW/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "核心設定" + "core": "核心設定", + "tabs": { + "basic": "基本資訊", + "routing": "路由", + "limits": "限制", + "network": "網路", + "testing": "測試" + } } diff --git a/messages/zh-TW/settings/providers/form/key.json b/messages/zh-TW/settings/providers/form/key.json index 5ff355db8..f89c40db5 100644 --- a/messages/zh-TW/settings/providers/form/key.json +++ b/messages/zh-TW/settings/providers/form/key.json @@ -1,6 +1,7 @@ { "currentKey": "當前金鑰:{key}", "label": "API 金鑰", + "labelEdit": "API 金鑰(留空不變更)", "leaveEmpty": "(留空不變更)", "leaveEmptyDesc": "留空則不變更金鑰", "placeholder": "輸入 API 金鑰" diff --git a/messages/zh-TW/settings/providers/form/name.json b/messages/zh-TW/settings/providers/form/name.json index f62ae57ca..b4f81798a 100644 --- a/messages/zh-TW/settings/providers/form/name.json +++ b/messages/zh-TW/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "供應商名稱 *", + "label": "供應商名稱", "placeholder": "例如:智譜" } diff --git a/messages/zh-TW/settings/providers/form/quickPaste.json b/messages/zh-TW/settings/providers/form/quickPaste.json new file mode 100644 index 000000000..838ddd646 --- /dev/null +++ b/messages/zh-TW/settings/providers/form/quickPaste.json @@ -0,0 +1,15 @@ +{ + "button": "快速貼上", + "title": "快速貼上", + "description": "貼上包含供應商資訊的文字(URL、API 金鑰等),系統將自動擷取。", + "placeholder": "在此貼上供應商設定文字...", + "preview": "偵測到的資訊", + "type": "類型", + "name": "名稱", + "url": "URL", + "key": "API 金鑰", + "notFound": "未偵測到", + "parseError": "無法從文字中解析供應商資訊", + "cancel": "取消", + "confirm": "套用" +} diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 8ac597e64..e5cb7ba6a 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "供應商身份", + "desc": "設定唯一名稱以識別此供應商" + }, + "endpoint": { + "title": "API 端點", + "desc": "設定 API 請求的基礎 URL" + }, + "auth": { + "title": "身份驗證", + "desc": "提供用於驗證的 API 金鑰" + } + }, "apiTest": { "desc": "測試供應商模型是否可用,預設與路由設定中選擇的供應商類型保持一致。", "summary": "驗證供應商與模型連通性", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "連續失敗多少次後觸發熔斷", "label": "失敗閾值(次)", - "placeholder": "5" + "placeholder": "5", + "warning": "設為 0 將停用斷路器 - 請謹慎使用" }, "maxRetryAttempts": { "desc": "包含首次呼叫在內,單一供應商最多嘗試次數,超過即切換。留空使用系統預設值。", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "流量限制", + "desc": "設定不同時間視窗的消費限制以控制成本", "dailyResetMode": { "desc": { "fixed": "每天固定時間點重置額度", @@ -164,6 +181,11 @@ }, "title": "流量限制" }, + "limits": { + "timeBased": "時間維度限制", + "dailyReset": "每日重置設定", + "otherLimits": "其他限制" + }, "routing": { "cacheTtl": { "desc": "強制設定 prompt cache TTL;僅影響包含 cache_control 的請求。", diff --git a/messages/zh-TW/settings/providers/form/url.json b/messages/zh-TW/settings/providers/form/url.json index 10f2d0b7f..ef27b7296 100644 --- a/messages/zh-TW/settings/providers/form/url.json +++ b/messages/zh-TW/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API 位址 *", + "label": "API 位址", "placeholder": "例如:https://open.bigmodel.cn/api/anthropic" } diff --git a/package.json b/package.json index c6eae1017..cd78c2182 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "dotenv": "^17", "drizzle-orm": "^0.44", "fetch-socks": "^1.3.2", + "framer-motion": "^12.28.1", "hono": "^4", "html2canvas": "^1", "ioredis": "^5", diff --git a/src/actions/overview.ts b/src/actions/overview.ts index d5cb237a8..b022c584d 100644 --- a/src/actions/overview.ts +++ b/src/actions/overview.ts @@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; -import { getOverviewMetrics as getOverviewMetricsFromDB } from "@/repository/overview"; +import { getOverviewMetricsWithComparison } from "@/repository/overview"; import { getSystemSettings } from "@/repository/system-config"; import { getConcurrentSessions as getConcurrentSessionsCount } from "./concurrent-sessions"; import type { ActionResult } from "./types"; @@ -21,11 +21,21 @@ export interface OverviewData { avgResponseTime: number; /** 今日错误率(百分比) */ todayErrorRate: number; + /** 昨日同时段请求数 */ + yesterdaySamePeriodRequests: number; + /** 昨日同时段消耗 */ + yesterdaySamePeriodCost: number; + /** 昨日同时段平均响应时间 */ + yesterdaySamePeriodAvgResponseTime: number; + /** 最近1分钟请求数 (RPM) */ + recentMinuteRequests: number; } /** * 获取概览数据(首页实时面板使用) - * 权限控制:管理员或 allowGlobalUsageView=true 时显示全站数据 + * 权限控制: + * - 管理员或 allowGlobalUsageView=true 时显示全站数据 + * - 否则显示当前用户自己的数据 */ export async function getOverviewData(): Promise> { try { @@ -42,40 +52,24 @@ export async function getOverviewData(): Promise> { const isAdmin = session.user.role === "admin"; const canViewGlobalData = isAdmin || settings.allowGlobalUsageView; + // 根据权限决定查询范围 + const userId = canViewGlobalData ? undefined : session.user.id; + // 并行查询所有数据 const [concurrentResult, metricsData] = await Promise.all([ - getConcurrentSessionsCount(), - getOverviewMetricsFromDB(), + // 并发数只有管理员能看全站的 + isAdmin ? getConcurrentSessionsCount() : Promise.resolve({ ok: true as const, data: 0 }), + getOverviewMetricsWithComparison(userId), ]); - // 根据权限决定显示范围 - if (!canViewGlobalData) { - // 普通用户且无权限:全站指标设为 0 - logger.debug("Overview: User without global view permission", { - userId: session.user.id, - userName: session.user.name, - }); - - return { - ok: true, - data: { - concurrentSessions: 0, // 无权限时不显示全站并发数 - todayRequests: 0, // 无权限时不显示全站请求数 - todayCost: 0, // 无权限时不显示全站消耗 - avgResponseTime: 0, // 无权限时不显示全站平均响应时间 - todayErrorRate: 0, // 无权限时不显示全站错误率 - }, - }; - } - - // 管理员或有权限:显示全站数据 const concurrentSessions = concurrentResult.ok ? concurrentResult.data : 0; - logger.debug("Overview: User with global view permission", { + logger.debug("Overview: Fetching data", { userId: session.user.id, userName: session.user.name, isAdmin, - allowGlobalUsageView: settings.allowGlobalUsageView, + canViewGlobalData, + queryScope: userId ? "user" : "global", }); return { @@ -86,6 +80,10 @@ export async function getOverviewData(): Promise> { todayCost: metricsData.todayCost, avgResponseTime: metricsData.avgResponseTime, todayErrorRate: metricsData.todayErrorRate, + yesterdaySamePeriodRequests: metricsData.yesterdaySamePeriodRequests, + yesterdaySamePeriodCost: metricsData.yesterdaySamePeriodCost, + yesterdaySamePeriodAvgResponseTime: metricsData.yesterdaySamePeriodAvgResponseTime, + recentMinuteRequests: metricsData.recentMinuteRequests, }, }; } catch (error) { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 3bade7aa3..7cbd01203 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -992,6 +992,168 @@ export async function resetProviderTotalUsage(providerId: number): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const { providerIds, updates } = params; + + if (!providerIds || providerIds.length === 0) { + return { ok: false, error: "请选择要更新的供应商" }; + } + + if (providerIds.length > BATCH_OPERATION_MAX_SIZE) { + return { ok: false, error: `单次批量操作最多支持 ${BATCH_OPERATION_MAX_SIZE} 个供应商` }; + } + + const hasUpdates = Object.values(updates).some((v) => v !== undefined); + if (!hasUpdates) { + return { ok: false, error: "请指定要更新的字段" }; + } + + const { updateProvidersBatch } = await import("@/repository/provider"); + + const repositoryUpdates: Parameters[1] = {}; + if (updates.is_enabled !== undefined) repositoryUpdates.isEnabled = updates.is_enabled; + if (updates.priority !== undefined) repositoryUpdates.priority = updates.priority; + if (updates.weight !== undefined) repositoryUpdates.weight = updates.weight; + if (updates.cost_multiplier !== undefined) { + repositoryUpdates.costMultiplier = updates.cost_multiplier.toString(); + } + if (updates.group_tag !== undefined) repositoryUpdates.groupTag = updates.group_tag; + + const updatedCount = await updateProvidersBatch(providerIds, repositoryUpdates); + + await broadcastProviderCacheInvalidation({ + operation: "edit", + providerId: providerIds[0], + }); + + logger.info("batchUpdateProviders:completed", { + requestedCount: providerIds.length, + updatedCount, + fields: Object.keys(updates).filter((k) => updates[k as keyof typeof updates] !== undefined), + }); + + return { ok: true, data: { updatedCount } }; + } catch (error) { + logger.error("批量更新供应商失败:", error); + const message = error instanceof Error ? error.message : "批量更新供应商失败"; + return { ok: false, error: message }; + } +} + +export interface BatchDeleteProvidersParams { + providerIds: number[]; +} + +export async function batchDeleteProviders( + params: BatchDeleteProvidersParams +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const { providerIds } = params; + + if (!providerIds || providerIds.length === 0) { + return { ok: false, error: "请选择要删除的供应商" }; + } + + if (providerIds.length > BATCH_OPERATION_MAX_SIZE) { + return { ok: false, error: `单次批量操作最多支持 ${BATCH_OPERATION_MAX_SIZE} 个供应商` }; + } + + const { deleteProvidersBatch } = await import("@/repository/provider"); + + const deletedCount = await deleteProvidersBatch(providerIds); + + for (const id of providerIds) { + clearProviderState(id); + clearConfigCache(id); + } + + await broadcastProviderCacheInvalidation({ + operation: "remove", + providerId: providerIds[0], + }); + + logger.info("batchDeleteProviders:completed", { + requestedCount: providerIds.length, + deletedCount, + }); + + return { ok: true, data: { deletedCount } }; + } catch (error) { + logger.error("批量删除供应商失败:", error); + const message = error instanceof Error ? error.message : "批量删除供应商失败"; + return { ok: false, error: message }; + } +} + +export interface BatchResetCircuitParams { + providerIds: number[]; +} + +export async function batchResetProviderCircuits( + params: BatchResetCircuitParams +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const { providerIds } = params; + + if (!providerIds || providerIds.length === 0) { + return { ok: false, error: "请选择要重置的供应商" }; + } + + if (providerIds.length > BATCH_OPERATION_MAX_SIZE) { + return { ok: false, error: `单次批量操作最多支持 ${BATCH_OPERATION_MAX_SIZE} 个供应商` }; + } + + let resetCount = 0; + for (const id of providerIds) { + resetCircuit(id); + clearConfigCache(id); + resetCount++; + } + + logger.info("batchResetProviderCircuits:completed", { + requestedCount: providerIds.length, + resetCount, + }); + + return { ok: true, data: { resetCount } }; + } catch (error) { + logger.error("批量重置熔断器失败:", error); + const message = error instanceof Error ? error.message : "批量重置熔断器失败"; + return { ok: false, error: message }; + } +} + /** * 获取供应商限额使用情况 */ diff --git a/src/app/[locale]/dashboard/_components/bento/bento-grid.tsx b/src/app/[locale]/dashboard/_components/bento/bento-grid.tsx new file mode 100644 index 000000000..392f28826 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/bento-grid.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { forwardRef, type KeyboardEvent, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface BentoGridProps { + children: ReactNode; + className?: string; +} + +/** + * Bento Grid Container + * Responsive grid layout with asymmetric card sizes + */ +export function BentoGrid({ children, className }: BentoGridProps) { + return ( +
+ {children} +
+ ); +} + +interface BentoCardProps { + children: ReactNode; + className?: string; + colSpan?: 1 | 2 | 3 | 4; + rowSpan?: 1 | 2 | 3; + interactive?: boolean; + onClick?: () => void; +} + +const colSpanClasses = { + 1: "", + 2: "sm:col-span-2", + 3: "sm:col-span-2 lg:col-span-3", + 4: "sm:col-span-2 lg:col-span-4", +}; + +const rowSpanClasses = { + 1: "", + 2: "row-span-2", + 3: "row-span-3", +}; + +/** + * Bento Card Component + * Individual card within the Bento Grid with glassmorphism styling + */ +export const BentoCard = forwardRef( + ({ children, className, colSpan = 1, rowSpan = 1, interactive = false, onClick }, ref) => { + const handleKeyDown = (e: KeyboardEvent) => { + if (interactive && onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick(); + } + }; + + return ( +
+ {/* Content wrapper to ensure z-index above pseudo-element */} +
{children}
+
+ ); + } +); + +BentoCard.displayName = "BentoCard"; diff --git a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx new file mode 100644 index 000000000..af074889f --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity, Clock, DollarSign, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { getActiveSessions } from "@/actions/active-sessions"; +import type { OverviewData } from "@/actions/overview"; +import { getOverviewData } from "@/actions/overview"; +import { getUserStatistics } from "@/actions/statistics"; +import type { CurrencyCode } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/currency"; +import type { + LeaderboardEntry, + ModelLeaderboardEntry, + ProviderLeaderboardEntry, +} from "@/repository/leaderboard"; +import type { ActiveSessionInfo } from "@/types/session"; +import type { TimeRange, UserStatisticsData } from "@/types/statistics"; +import { DEFAULT_TIME_RANGE } from "@/types/statistics"; +import { BentoGrid } from "./bento-grid"; +import { LeaderboardCard } from "./leaderboard-card"; +import { LiveSessionsPanel } from "./live-sessions-panel"; +import { BentoMetricCard } from "./metric-card"; +import { StatisticsChartCard } from "./statistics-chart-card"; + +const REFRESH_INTERVAL = 5000; + +interface DashboardBentoProps { + isAdmin: boolean; + currencyCode: CurrencyCode; + allowGlobalUsageView: boolean; + initialStatistics?: UserStatisticsData; +} + +interface LeaderboardData { + id: string | number; + name: string; + totalRequests: number; + totalTokens: number; + totalCost: number; +} + +async function fetchOverviewData(): Promise { + const result = await getOverviewData(); + if (!result.ok) throw new Error(result.error || "Failed to fetch overview"); + return result.data; +} + +async function fetchActiveSessions(): Promise { + const result = await getActiveSessions(); + if (!result.ok) throw new Error(result.error || "Failed to fetch sessions"); + return result.data; +} + +async function fetchStatistics(timeRange: TimeRange): Promise { + const result = await getUserStatistics(timeRange); + if (!result.ok) return null; + return result.data; +} + +async function fetchLeaderboard(scope: "user" | "provider" | "model"): Promise { + const res = await fetch(`/api/leaderboard?period=daily&scope=${scope}`, { + cache: "no-store", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to fetch leaderboard"); + const data = await res.json(); + + if (scope === "user") { + return (data as LeaderboardEntry[]).map((item) => ({ + id: `user-${item.userId}`, + name: item.userName ?? "", + totalRequests: item.totalRequests ?? 0, + totalTokens: item.totalTokens ?? 0, + totalCost: item.totalCost ?? 0, + })); + } + if (scope === "provider") { + return (data as ProviderLeaderboardEntry[]).map((item) => ({ + id: `provider-${item.providerId}`, + name: item.providerName ?? "", + totalRequests: item.totalRequests ?? 0, + totalTokens: item.totalTokens ?? 0, + totalCost: item.totalCost ?? 0, + })); + } + return (data as ModelLeaderboardEntry[]).map((item) => ({ + id: `model-${item.model}`, + name: item.model ?? "", + totalRequests: item.totalRequests ?? 0, + totalTokens: item.totalTokens ?? 0, + totalCost: item.totalCost ?? 0, + })); +} + +/** + * Calculate percentage change between current and previous values + */ +function calcPercentageChange(current: number, previous: number): number { + if (previous === 0) { + return current > 0 ? 100 : 0; + } + return Math.round(((current - previous) / previous) * 100); +} + +export function DashboardBento({ + isAdmin, + currencyCode, + allowGlobalUsageView, + initialStatistics, +}: DashboardBentoProps) { + const t = useTranslations("customs"); + const tl = useTranslations("dashboard.leaderboard"); + + const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); + + // Overview metrics (available to all users, but shows different data based on permissions) + const { data: overview } = useQuery({ + queryKey: ["overview-data"], + queryFn: fetchOverviewData, + refetchInterval: REFRESH_INTERVAL, + }); + + // Active sessions + const { data: sessions = [], isLoading: sessionsLoading } = useQuery({ + queryKey: ["active-sessions"], + queryFn: fetchActiveSessions, + refetchInterval: REFRESH_INTERVAL, + enabled: isAdmin, + }); + + // Statistics + const { data: statistics } = useQuery({ + queryKey: ["statistics", timeRange], + queryFn: () => fetchStatistics(timeRange), + initialData: timeRange === DEFAULT_TIME_RANGE ? initialStatistics : undefined, + }); + + // Leaderboards + const { data: userLeaderboard = [], isLoading: userLeaderboardLoading } = useQuery< + LeaderboardData[] + >({ + queryKey: ["leaderboard", "user"], + queryFn: () => fetchLeaderboard("user"), + enabled: isAdmin || allowGlobalUsageView, + }); + + const { data: providerLeaderboard = [], isLoading: providerLeaderboardLoading } = useQuery< + LeaderboardData[] + >({ + queryKey: ["leaderboard", "provider"], + queryFn: () => fetchLeaderboard("provider"), + enabled: isAdmin || allowGlobalUsageView, + }); + + const { data: modelLeaderboard = [], isLoading: modelLeaderboardLoading } = useQuery< + LeaderboardData[] + >({ + queryKey: ["leaderboard", "model"], + queryFn: () => fetchLeaderboard("model"), + enabled: isAdmin || allowGlobalUsageView, + }); + + const metrics = overview || { + concurrentSessions: 0, + todayRequests: 0, + todayCost: 0, + avgResponseTime: 0, + todayErrorRate: 0, + yesterdaySamePeriodRequests: 0, + yesterdaySamePeriodCost: 0, + yesterdaySamePeriodAvgResponseTime: 0, + recentMinuteRequests: 0, + }; + + const formatResponseTime = (ms: number) => { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; + }; + + // Calculate comparisons + const requestsChange = calcPercentageChange( + metrics.todayRequests, + metrics.yesterdaySamePeriodRequests + ); + const costChange = calcPercentageChange(metrics.todayCost, metrics.yesterdaySamePeriodCost); + const responseTimeChange = calcPercentageChange( + metrics.avgResponseTime, + metrics.yesterdaySamePeriodAvgResponseTime + ); + + // Sessions with lastActivityAt for LiveSessionsPanel + const sessionsWithActivity = useMemo(() => { + return sessions.map((s) => ({ + ...s, + lastActivityAt: s.startTime, + })); + }, [sessions]); + + const canViewLeaderboard = isAdmin || allowGlobalUsageView; + + return ( +
+ {/* Top Section: Metrics + Live Sessions */} + {isAdmin && ( + + {/* Metric Cards */} + + + + + + )} + + {/* Middle Section: Statistics Chart + Live Sessions (Admin) */} + + {/* Statistics Chart - 3 columns for admin, 4 columns for non-admin */} + {statistics && ( + + )} + + {/* Live Sessions Panel - Right sidebar, spans 2 rows */} + {isAdmin && ( + + )} + + {/* Leaderboard Cards - Below chart, 3 columns */} + {canViewLeaderboard && ( + + )} + {canViewLeaderboard && ( + + )} + {canViewLeaderboard && ( + + )} + +
+ ); +} diff --git a/src/app/[locale]/dashboard/_components/bento/index.ts b/src/app/[locale]/dashboard/_components/bento/index.ts new file mode 100644 index 000000000..8b4451964 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/index.ts @@ -0,0 +1,6 @@ +export { BentoCard, BentoGrid } from "./bento-grid"; +export { DashboardBento } from "./dashboard-bento"; +export { LeaderboardCard } from "./leaderboard-card"; +export { LiveSessionsPanel } from "./live-sessions-panel"; +export { BentoMetricCard } from "./metric-card"; +export { StatisticsChartCard } from "./statistics-chart-card"; diff --git a/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx b/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx new file mode 100644 index 000000000..72b027c8d --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { Award, ChevronRight, Medal, Trophy } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { cn, formatCurrency, formatTokenAmount } from "@/lib/utils"; +import type { CurrencyCode } from "@/lib/utils/currency"; +import { BentoCard } from "./bento-grid"; + +interface LeaderboardEntry { + id: string | number; + name: string; + totalRequests: number; + totalTokens: number; + totalCost: number; +} + +interface LeaderboardCardProps { + title: string; + entries: LeaderboardEntry[]; + currencyCode: CurrencyCode; + isLoading?: boolean; + emptyText?: string; + viewAllHref?: string; + maxItems?: number; + accentColor?: "primary" | "purple" | "blue"; + className?: string; +} + +const accentColors = { + primary: { + bar: "bg-primary", + progress: "bg-muted/40 dark:bg-white/10", + }, + purple: { + bar: "bg-purple-500", + progress: "bg-muted/40 dark:bg-white/10", + }, + blue: { + bar: "bg-blue-500", + progress: "bg-muted/40 dark:bg-white/10", + }, +}; + +const rankConfig = [ + { + icon: Trophy, + iconColor: "text-amber-500", + bgColor: "bg-amber-500/10 dark:bg-amber-500/20", + borderColor: "border-amber-500/20", + }, + { + icon: Medal, + iconColor: "text-slate-400", + bgColor: "bg-slate-400/10 dark:bg-slate-400/20", + borderColor: "border-slate-400/20", + }, + { + icon: Award, + iconColor: "text-orange-600", + bgColor: "bg-orange-600/10 dark:bg-orange-600/20", + borderColor: "border-orange-600/20", + }, +]; + +function RankBadge({ rank }: { rank: number }) { + const config = rankConfig[rank - 1]; + + if (!config) { + return ( +
+ #{rank} +
+ ); + } + + const Icon = config.icon; + + return ( +
+ +
+ ); +} + +function LeaderboardItem({ + entry, + rank, + maxCost, + currencyCode, + accentColor, +}: { + entry: LeaderboardEntry; + rank: number; + maxCost: number; + currencyCode: CurrencyCode; + accentColor: "primary" | "purple" | "blue"; +}) { + const percentage = maxCost > 0 ? (entry.totalCost / maxCost) * 100 : 0; + const colors = accentColors[accentColor]; + const t = useTranslations("dashboard.leaderboard"); + + return ( +
+ + +
+
+ {entry.name} + + {formatCurrency(entry.totalCost, currencyCode)} + +
+ + {/* Progress Bar */} +
+
+
+ +
+ + {entry.totalRequests.toLocaleString()} {t("requests")} + + + {formatTokenAmount(entry.totalTokens)} {t("tokens")} + +
+
+
+ ); +} + +/** + * Leaderboard Card + * Displays ranked list with progress bars and glass morphism + */ +export function LeaderboardCard({ + title, + entries, + currencyCode, + isLoading, + emptyText, + viewAllHref, + maxItems = 3, + accentColor = "primary", + className, +}: LeaderboardCardProps) { + const router = useRouter(); + const t = useTranslations("dashboard.leaderboard"); + + const displayEntries = entries.slice(0, maxItems); + const maxCost = Math.max(...entries.map((e) => e.totalCost), 0); + + return ( + + {/* Header */} +
+

{title}

+ {viewAllHref && ( + + )} +
+ + {/* Content */} +
+ {isLoading ? ( + // Loading Skeletons + Array.from({ length: maxItems }).map((_, idx) => ( +
+
+
+
+
+
+
+ )) + ) : displayEntries.length === 0 ? ( +
+ {emptyText || t("noData")} +
+ ) : ( + displayEntries.map((entry, idx) => ( + + )) + )} +
+ + ); +} diff --git a/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx new file mode 100644 index 000000000..344470b4e --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { Activity, AlertCircle, CheckCircle2, Circle, XCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { ActiveSessionInfo } from "@/types/session"; +import { BentoCard } from "./bento-grid"; + +interface LiveSessionsPanelProps { + sessions: (ActiveSessionInfo & { lastActivityAt?: number })[]; + isLoading?: boolean; + maxItems?: number; + className?: string; +} + +type SessionStatus = "running" | "idle" | "error" | "done" | "init"; + +function getSessionStatus(session: ActiveSessionInfo & { lastActivityAt?: number }): SessionStatus { + // Determine status based on session activity and startTime + const now = Date.now(); + const lastActivity = session.lastActivityAt ?? session.startTime; + const idleThreshold = 60 * 1000; // 1 minute + + if (session.status === "error" || (session as { status?: string }).status === "error") { + return "error"; + } + + if (now - lastActivity < 5000) { + return "running"; + } + + if (now - lastActivity < idleThreshold) { + return "init"; + } + + return "idle"; +} + +const statusConfig: Record< + SessionStatus, + { icon: typeof Circle; color: string; pulse?: boolean; labelKey: string } +> = { + running: { + icon: Circle, + color: "text-emerald-500 dark:text-emerald-400", + pulse: true, + labelKey: "status.running", + }, + init: { + icon: Circle, + color: "text-amber-500 dark:text-amber-400", + pulse: true, + labelKey: "status.init", + }, + idle: { + icon: Circle, + color: "text-muted-foreground/50", + pulse: false, + labelKey: "status.idle", + }, + error: { + icon: XCircle, + color: "text-rose-500 dark:text-rose-400", + pulse: true, + labelKey: "status.error", + }, + done: { + icon: CheckCircle2, + color: "text-muted-foreground/50", + pulse: false, + labelKey: "status.done", + }, +}; + +function SessionItem({ session }: { session: ActiveSessionInfo }) { + const router = useRouter(); + const t = useTranslations("customs.activeSessions"); + const status = getSessionStatus(session); + const config = statusConfig[status]; + const StatusIcon = config.icon; + + const shortId = session.sessionId.slice(-6); + const userName = session.userName || t("unknownUser"); + + return ( + + ); +} + +const SESSION_ITEM_HEIGHT = 36; // Height of each session row in pixels +const HEADER_HEIGHT = 48; // Height of header +const FOOTER_HEIGHT = 36; // Height of footer + +/** + * Live Sessions Panel + * Terminal-style display of active sessions with real-time status indicators + */ +export function LiveSessionsPanel({ + sessions, + isLoading, + maxItems: maxItemsProp, + className, +}: LiveSessionsPanelProps) { + const t = useTranslations("customs.activeSessions"); + const router = useRouter(); + const containerRef = useRef(null); + const [dynamicMaxItems, setDynamicMaxItems] = useState(maxItemsProp ?? 8); + + const calculateMaxItems = useCallback(() => { + if (!containerRef.current) return; + const containerHeight = containerRef.current.clientHeight; + const availableHeight = containerHeight - HEADER_HEIGHT - FOOTER_HEIGHT; + let calculatedItems = Math.max(1, Math.floor(availableHeight / SESSION_ITEM_HEIGHT)); + if (maxItemsProp !== undefined) { + calculatedItems = Math.min(calculatedItems, maxItemsProp); + } + setDynamicMaxItems(calculatedItems); + }, [maxItemsProp]); + + useEffect(() => { + calculateMaxItems(); + const resizeObserver = new ResizeObserver(calculateMaxItems); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + return () => resizeObserver.disconnect(); + }, [calculateMaxItems]); + + const displaySessions = sessions.slice(0, dynamicMaxItems); + const hasMore = sessions.length > dynamicMaxItems; + + return ( + + {/* Scanline Overlay - only visible in dark mode */} +
+ + {/* Header */} +
+
+ + + {t("title")} + +
+ {/* Traffic Lights */} +
+
+
+
+
+
+ + {/* Sessions List */} +
+ {isLoading && sessions.length === 0 ? ( +
+
+
+ {t("loading")} +
+
+ ) : sessions.length === 0 ? ( +
+ + {t("empty")} +
+ ) : ( + displaySessions.map((session) => ( + + )) + )} +
+ + {/* Footer */} + {(hasMore || sessions.length > 0) && ( + + )} + + ); +} diff --git a/src/app/[locale]/dashboard/_components/bento/metric-card.tsx b/src/app/[locale]/dashboard/_components/bento/metric-card.tsx new file mode 100644 index 000000000..0cd0642ee --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/metric-card.tsx @@ -0,0 +1,283 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ComparisonData { + value: number; + label: string; + isPercentage?: boolean; +} + +interface MetricCardProps { + title: string; + value: number | string; + icon?: LucideIcon; + trend?: { + value: number; + direction: "up" | "down" | "stable"; + label?: string; + }; + comparisons?: ComparisonData[]; + formatter?: (value: number) => string; + onClick?: () => void; + className?: string; + accentColor?: "primary" | "emerald" | "blue" | "amber" | "purple" | "rose"; +} + +const accentColors = { + primary: { + glow: "bg-primary/10", + glowBlur: "blur-[50px]", + iconBg: "bg-primary/10 dark:bg-primary/15", + text: "text-primary", + }, + emerald: { + glow: "bg-emerald-500/10", + glowBlur: "blur-[50px]", + iconBg: "bg-emerald-500/10 dark:bg-emerald-500/15", + text: "text-emerald-500", + }, + blue: { + glow: "bg-blue-500/10", + glowBlur: "blur-[50px]", + iconBg: "bg-blue-500/10 dark:bg-blue-500/15", + text: "text-blue-500", + }, + amber: { + glow: "bg-amber-500/10", + glowBlur: "blur-[50px]", + iconBg: "bg-amber-500/10 dark:bg-amber-500/15", + text: "text-amber-500", + }, + purple: { + glow: "bg-purple-500/10", + glowBlur: "blur-[50px]", + iconBg: "bg-purple-500/10 dark:bg-purple-500/15", + text: "text-purple-500", + }, + rose: { + glow: "bg-rose-500/10", + glowBlur: "blur-[50px]", + iconBg: "bg-rose-500/10 dark:bg-rose-500/15", + text: "text-rose-500", + }, +}; + +const trendConfig = { + up: { + bg: "bg-emerald-500/10 dark:bg-emerald-500/15", + text: "text-emerald-500 dark:text-emerald-400", + border: "border border-emerald-500/20", + }, + down: { + bg: "bg-rose-500/10 dark:bg-rose-500/15", + text: "text-rose-500 dark:text-rose-400", + border: "border border-rose-500/20", + }, + stable: { + bg: "bg-muted/50", + text: "text-muted-foreground", + border: "border border-muted/30", + }, +}; + +function ComparisonBadge({ value, label, isPercentage = true }: ComparisonData) { + const isPositive = value > 0; + const isNegative = value < 0; + const Icon = isPositive ? ArrowUp : isNegative ? ArrowDown : ArrowRight; + + return ( +
+
+ + + {value > 0 ? "+" : ""} + {value} + {isPercentage && "%"} + +
+ {label} +
+ ); +} + +export function BentoMetricCard({ + title, + value, + icon: Icon, + trend, + comparisons, + formatter, + onClick, + className, + accentColor = "primary", +}: MetricCardProps) { + const [displayValue, setDisplayValue] = useState(value); + const [isAnimating, setIsAnimating] = useState(false); + const prevValueRef = useRef(value); + const colors = accentColors[accentColor]; + + useEffect(() => { + let cancelled = false; + if (typeof value === "number" && typeof prevValueRef.current === "number") { + if (value !== prevValueRef.current) { + setIsAnimating(true); + const duration = 400; + const startValue = prevValueRef.current; + const diff = value - startValue; + const startTime = Date.now(); + + const animate = () => { + if (cancelled) return; + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const easeProgress = 1 - (1 - progress) ** 3; + const currentValue = startValue + diff * easeProgress; + + setDisplayValue(currentValue); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setIsAnimating(false); + prevValueRef.current = value; + } + }; + + requestAnimationFrame(animate); + } + } else { + setDisplayValue(value); + prevValueRef.current = value; + } + return () => { + cancelled = true; + }; + }, [value]); + + const formattedValue = + typeof displayValue === "number" && formatter + ? formatter(Math.round(displayValue)) + : typeof displayValue === "number" + ? Math.round(displayValue).toLocaleString() + : displayValue; + + const TrendIcon = + trend?.direction === "up" ? ArrowUp : trend?.direction === "down" ? ArrowDown : ArrowRight; + + const trendStyle = trend ? trendConfig[trend.direction] : null; + + const Component = onClick ? "button" : "div"; + const componentProps = onClick ? { type: "button" as const } : {}; + + return ( + + {/* Subtle Mesh Gradient Glow */} +
+ + {/* Content */} +
+ {/* Header */} +
+
+ {Icon && ( +
+ +
+ )} +

+ {title} +

+
+ + {/* Trend Badge */} + {trend && trendStyle && ( +
+ + + {trend.value > 0 ? "+" : ""} + {trend.value}% + +
+ )} +
+ + {/* Value */} +
+

+ {formattedValue} +

+ {trend?.label &&

{trend.label}

} +
+
+ + {/* Comparison Section */} + {comparisons && comparisons.length > 0 && ( +
+ {comparisons.map((comparison, index) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx new file mode 100644 index 000000000..30317ba3e --- /dev/null +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { 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"; +import type { CurrencyCode } from "@/lib/utils"; +import { cn, Decimal, formatCurrency, toDecimal } from "@/lib/utils"; +import type { TimeRange, UserStatisticsData } from "@/types/statistics"; +import { TIME_RANGE_OPTIONS } from "@/types/statistics"; +import { BentoCard } from "./bento-grid"; + +const USER_COLOR_PALETTE = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "hsl(15, 85%, 60%)", + "hsl(195, 85%, 60%)", + "hsl(285, 85%, 60%)", + "hsl(135, 85%, 50%)", + "hsl(45, 85%, 55%)", +] as const; + +const getUserColor = (index: number) => USER_COLOR_PALETTE[index % USER_COLOR_PALETTE.length]; + +export interface StatisticsChartCardProps { + data: UserStatisticsData; + onTimeRangeChange?: (timeRange: TimeRange) => void; + currencyCode?: CurrencyCode; + colSpan?: 3 | 4; + className?: string; +} + +export function StatisticsChartCard({ + data, + onTimeRangeChange, + currencyCode = "USD", + colSpan = 4, + className, +}: StatisticsChartCardProps) { + const t = useTranslations("dashboard.statistics"); + const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost"); + const [chartMode, setChartMode] = React.useState<"stacked" | "overlay">("overlay"); + + const [selectedUserIds, setSelectedUserIds] = React.useState>( + () => new Set(data.users.map((u) => u.id)) + ); + + React.useEffect(() => { + setSelectedUserIds(new Set(data.users.map((u) => u.id))); + }, [data.users]); + + const isAdminMode = data.mode === "users"; + const enableUserFilter = isAdminMode && data.users.length > 1; + + const toggleUserSelection = (userId: number) => { + setSelectedUserIds((prev) => { + const next = new Set(prev); + if (next.has(userId)) { + if (next.size > 1) { + next.delete(userId); + } + } else { + next.add(userId); + } + return next; + }); + }; + + const chartConfig = React.useMemo(() => { + const config: ChartConfig = { + cost: { label: t("cost") }, + calls: { label: t("calls") }, + }; + data.users.forEach((user, index) => { + config[user.dataKey] = { + label: user.name, + color: getUserColor(index), + }; + }); + return config; + }, [data.users, t]); + + const userMap = React.useMemo(() => { + return new Map(data.users.map((user) => [user.dataKey, user])); + }, [data.users]); + + const visibleUsers = React.useMemo(() => { + if (!enableUserFilter) return data.users; + return data.users.filter((u) => selectedUserIds.has(u.id)); + }, [data.users, selectedUserIds, enableUserFilter]); + + const numericChartData = React.useMemo(() => { + return data.chartData.map((day) => { + const normalized: Record = { ...day }; + visibleUsers.forEach((user) => { + const costKey = `${user.dataKey}_cost`; + const costDecimal = toDecimal(day[costKey]); + normalized[costKey] = costDecimal ? Number(costDecimal.toDecimalPlaces(6).toString()) : 0; + const callsKey = `${user.dataKey}_calls`; + const callsValue = day[callsKey]; + normalized[callsKey] = + typeof callsValue === "number" ? callsValue : Number(callsValue ?? 0); + }); + return normalized; + }); + }, [data.chartData, visibleUsers]); + + const userTotals = React.useMemo(() => { + const totals: Record = {}; + data.users.forEach((user) => { + totals[user.dataKey] = { cost: new Decimal(0), calls: 0 }; + }); + data.chartData.forEach((day) => { + data.users.forEach((user) => { + const costValue = toDecimal(day[`${user.dataKey}_cost`]); + const callsValue = day[`${user.dataKey}_calls`]; + if (costValue) { + const current = totals[user.dataKey]; + current.cost = current.cost.plus(costValue); + } + totals[user.dataKey].calls += + typeof callsValue === "number" ? callsValue : Number(callsValue ?? 0); + }); + }); + return totals; + }, [data.chartData, data.users]); + + const visibleTotals = React.useMemo(() => { + const costTotal = data.chartData.reduce((sum, day) => { + const dayTotal = visibleUsers.reduce((daySum, user) => { + const costValue = toDecimal(day[`${user.dataKey}_cost`]); + return costValue ? daySum.plus(costValue) : daySum; + }, new Decimal(0)); + return sum.plus(dayTotal); + }, new Decimal(0)); + + const callsTotal = data.chartData.reduce((sum, day) => { + const dayTotal = visibleUsers.reduce((daySum, user) => { + const callsValue = day[`${user.dataKey}_calls`]; + return daySum + (typeof callsValue === "number" ? callsValue : 0); + }, 0); + return sum + dayTotal; + }, 0); + + return { cost: costTotal, calls: callsTotal }; + }, [data.chartData, visibleUsers]); + + 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.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); + }; + + const formatTooltipDate = (dateStr: string) => { + const date = new Date(dateStr); + if (data.resolution === "hour") { + return date.toLocaleString("zh-CN", { + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + return ( + + {/* Header */} +
+
+

{t("title")}

+ {/* Chart Mode Toggle */} + {visibleUsers.length > 1 && ( +
+ + +
+ )} +
+ + {/* Time Range Selector */} + {onTimeRangeChange && ( +
+ {TIME_RANGE_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ + {/* Metric Tabs */} +
+ + +
+ + {/* Chart */} +
+ + + + {data.users.map((user, index) => { + const color = getUserColor(index); + return ( + + + + + ); + })} + + + + + activeChart === "cost" + ? formatCurrency(value, currencyCode) + : Number(value).toLocaleString() + } + className="text-[10px] fill-muted-foreground" + /> + { + if (!active || !payload?.length) return null; + const filteredPayload = payload.filter((entry) => { + const value = + typeof entry.value === "number" ? entry.value : Number(entry.value ?? 0); + return !Number.isNaN(value) && value !== 0; + }); + if (!filteredPayload.length) return null; + + return ( +
+
+ {formatTooltipDate(String(label ?? ""))} +
+
+ {[...filteredPayload] + .sort((a, b) => (Number(b.value ?? 0) || 0) - (Number(a.value ?? 0) || 0)) + .map((entry, index) => { + const baseKey = + entry.dataKey?.toString().replace(`_${activeChart}`, "") || ""; + const displayUser = userMap.get(baseKey); + const value = + typeof entry.value === "number" + ? entry.value + : Number(entry.value ?? 0); + return ( +
+
+
+ {displayUser?.name || baseKey} +
+ + {activeChart === "cost" + ? formatCurrency(value, currencyCode) + : value.toLocaleString()} + +
+ ); + })} +
+
+ ); + }} + /> + {(chartMode === "overlay" + ? [...visibleUsers].sort((a, b) => { + const totalA = userTotals[a.dataKey]; + const totalB = userTotals[b.dataKey]; + if (!totalA || !totalB) return 0; + if (activeChart === "cost") return totalB.cost.comparedTo(totalA.cost); + return totalB.calls - totalA.calls; + }) + : visibleUsers + ).map((user) => { + const originalIndex = data.users.findIndex((u) => u.id === user.id); + const color = getUserColor(originalIndex); + return ( + + ); + })} + + +
+ + {/* Legend */} + {enableUserFilter && ( +
+ {/* Control buttons */} +
+ + | + +
+ {/* User list with max 3 rows and scroll */} +
+
+ {data.users.map((user, index) => { + const color = getUserColor(index); + const isSelected = selectedUserIds.has(user.id); + const userTotal = userTotals[user.dataKey]; + return ( + + ); + })} +
+
+
+ )} + + ); +} diff --git a/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx b/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx new file mode 100644 index 000000000..32ed3b193 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx @@ -0,0 +1,27 @@ +import { cache } from "react"; +import { getUserStatistics } from "@/actions/statistics"; +import { getSystemSettings } from "@/repository/system-config"; +import { DEFAULT_TIME_RANGE } from "@/types/statistics"; +import { DashboardBento } from "./bento/dashboard-bento"; + +const getCachedSystemSettings = cache(getSystemSettings); + +interface DashboardBentoSectionProps { + isAdmin: boolean; +} + +export async function DashboardBentoSection({ isAdmin }: DashboardBentoSectionProps) { + const [systemSettings, statistics] = await Promise.all([ + getCachedSystemSettings(), + getUserStatistics(DEFAULT_TIME_RANGE), + ]); + + return ( + + ); +} diff --git a/src/app/[locale]/dashboard/page.bento.tsx b/src/app/[locale]/dashboard/page.bento.tsx new file mode 100644 index 000000000..74f52dc2a --- /dev/null +++ b/src/app/[locale]/dashboard/page.bento.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; +import { hasPriceTable } from "@/actions/model-prices"; +import { redirect } from "@/i18n/routing"; +import { getSession } from "@/lib/auth"; +import { DashboardBentoSection } from "./_components/dashboard-bento-sections"; +import { DashboardOverviewSkeleton } from "./_components/dashboard-skeletons"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + + const hasPrices = await hasPriceTable(); + if (!hasPrices) { + return redirect({ href: "/settings/prices?required=true", locale }); + } + + const session = await getSession(); + const isAdmin = session?.user?.role === "admin"; + + return ( + }> + + + ); +} diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index a769b3b3e..74f52dc2a 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -2,24 +2,14 @@ import { Suspense } from "react"; import { hasPriceTable } from "@/actions/model-prices"; import { redirect } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { - DashboardLeaderboardSection, - DashboardOverviewSection, - DashboardStatisticsSection, -} from "./_components/dashboard-sections"; -import { - DashboardLeaderboardSkeleton, - DashboardOverviewSkeleton, - DashboardStatisticsSkeleton, -} from "./_components/dashboard-skeletons"; +import { DashboardBentoSection } from "./_components/dashboard-bento-sections"; +import { DashboardOverviewSkeleton } from "./_components/dashboard-skeletons"; export const dynamic = "force-dynamic"; export default async function DashboardPage({ params }: { params: Promise<{ locale: string }> }) { - // Await params to ensure locale is available in the async context const { locale } = await params; - // 检查价格表是否存在,如果不存在则跳转到价格上传页面 const hasPrices = await hasPriceTable(); if (!hasPrices) { return redirect({ href: "/settings/prices?required=true", locale }); @@ -29,20 +19,8 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca const isAdmin = session?.user?.role === "admin"; return ( -
- {isAdmin ? ( - }> - - - ) : null} - - }> - - - - }> - - -
+ }> + + ); } diff --git a/src/app/[locale]/settings/_components/page-transition.tsx b/src/app/[locale]/settings/_components/page-transition.tsx new file mode 100644 index 000000000..fb38d364e --- /dev/null +++ b/src/app/[locale]/settings/_components/page-transition.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +interface PageTransitionProps { + children: ReactNode; +} + +export function PageTransition({ children }: PageTransitionProps) { + const pathname = usePathname(); + + return ( + + {children} + + ); +} diff --git a/src/app/[locale]/settings/_components/settings-nav.tsx b/src/app/[locale]/settings/_components/settings-nav.tsx index a24c1e786..98c38c4c8 100644 --- a/src/app/[locale]/settings/_components/settings-nav.tsx +++ b/src/app/[locale]/settings/_components/settings-nav.tsx @@ -1,10 +1,46 @@ "use client"; +import { motion } from "framer-motion"; +import { + AlertTriangle, + Bell, + BookOpen, + Database, + DollarSign, + ExternalLink, + FileText, + Filter, + HelpCircle, + type LucideIcon, + MessageCircle, + Server, + Settings, + ShieldAlert, + Smartphone, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { ThemeSwitcher } from "@/components/ui/theme-switcher"; import { Link, usePathname } from "@/i18n/routing"; import { cn } from "@/lib/utils"; -import type { SettingsNavItem } from "../_lib/nav-items"; +import type { SettingsNavIconName, SettingsNavItem } from "../_lib/nav-items"; + +// Map icon names to actual icon components (client-side only) +const ICON_MAP: Record = { + settings: Settings, + "dollar-sign": DollarSign, + server: Server, + "shield-alert": ShieldAlert, + "alert-triangle": AlertTriangle, + filter: Filter, + smartphone: Smartphone, + database: Database, + "file-text": FileText, + bell: Bell, + "book-open": BookOpen, + "help-circle": HelpCircle, + "message-circle": MessageCircle, + "external-link": ExternalLink, +}; interface SettingsNavProps { items: SettingsNavItem[]; @@ -18,66 +54,224 @@ export function SettingsNav({ items }: SettingsNavProps) { return null; } + // Split internal and external items + const internalItems = items.filter((item) => !item.external); + const externalItems = items.filter((item) => item.external); + const getIsActive = (href: string) => { return pathname === href || pathname.startsWith(`${href}/`); }; + const getIcon = (iconName?: SettingsNavIconName): LucideIcon => { + if (!iconName) return Settings; + return ICON_MAP[iconName] || Settings; + }; + return ( - + + ); } diff --git a/src/app/[locale]/settings/_components/settings-page-header.tsx b/src/app/[locale]/settings/_components/settings-page-header.tsx index 45daaa7ca..03fb1e5bd 100644 --- a/src/app/[locale]/settings/_components/settings-page-header.tsx +++ b/src/app/[locale]/settings/_components/settings-page-header.tsx @@ -1,13 +1,122 @@ +import { + AlertTriangle, + Bell, + Database, + DollarSign, + FileText, + Filter, + type LucideIcon, + Settings, + ShieldAlert, + Smartphone, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// Icon name type for serialization across server/client boundary +export type PageHeaderIconName = + | "settings" + | "database" + | "file-text" + | "bell" + | "shield-alert" + | "alert-triangle" + | "filter" + | "smartphone" + | "dollar-sign"; + +// Map icon names to components +const HEADER_ICON_MAP: Record = { + settings: Settings, + database: Database, + "file-text": FileText, + bell: Bell, + "shield-alert": ShieldAlert, + "alert-triangle": AlertTriangle, + filter: Filter, + smartphone: Smartphone, + "dollar-sign": DollarSign, +}; + interface SettingsPageHeaderProps { title: string; description?: string; + icon?: PageHeaderIconName; + actions?: React.ReactNode; +} + +export function SettingsPageHeader({ title, description, icon, actions }: SettingsPageHeaderProps) { + const Icon = icon ? HEADER_ICON_MAP[icon] : null; + + return ( +
+
+
+ {Icon && ( +
+ +
+ )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {actions &&
{actions}
} +
+
+ ); +} + +// Compact header for sub-sections +interface SettingsSectionHeaderProps { + title: string; + description?: string; + icon?: LucideIcon; + iconColor?: string; + badge?: React.ReactNode; + actions?: React.ReactNode; + className?: string; } -export function SettingsPageHeader({ title, description }: SettingsPageHeaderProps) { +export function SettingsSectionHeader({ + title, + description, + icon: Icon, + iconColor = "text-muted-foreground", + badge, + actions, + className, +}: SettingsSectionHeaderProps) { return ( -
-

{title}

- {description ?

{description}

: null} +
+
+ {Icon && ( +
+ +
+ )} +
+
+

{title}

+ {badge} +
+ {description && ( +

{description}

+ )} +
+
+ {actions &&
{actions}
}
); } diff --git a/src/app/[locale]/settings/_components/ui/settings-ui.tsx b/src/app/[locale]/settings/_components/ui/settings-ui.tsx new file mode 100644 index 000000000..4cdac9d42 --- /dev/null +++ b/src/app/[locale]/settings/_components/ui/settings-ui.tsx @@ -0,0 +1,536 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + AlertTriangle, + Bell, + Database, + DollarSign, + Download, + FileText, + Filter, + FlaskConical, + HardDrive, + Link2, + type LucideIcon, + Power, + Settings, + ShieldAlert, + Smartphone, + Trash2, + TrendingUp, + Upload, + Webhook, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +// Icon name type for serialization across server/client boundary +export type SettingsSectionIconName = + | "settings" + | "trash" + | "database" + | "hard-drive" + | "download" + | "upload" + | "file-text" + | "bell" + | "webhook" + | "shield-alert" + | "alert-triangle" + | "filter" + | "smartphone" + | "dollar-sign" + | "link" + | "power" + | "trending-up" + | "flask-conical"; + +// Map icon names to components (client-side only) +const SETTINGS_SECTION_ICON_MAP: Record = { + settings: Settings, + trash: Trash2, + database: Database, + "hard-drive": HardDrive, + download: Download, + upload: Upload, + "file-text": FileText, + bell: Bell, + webhook: Webhook, + "shield-alert": ShieldAlert, + "alert-triangle": AlertTriangle, + filter: Filter, + smartphone: Smartphone, + "dollar-sign": DollarSign, + link: Link2, + power: Power, + "trending-up": TrendingUp, + "flask-conical": FlaskConical, +}; + +// Glass-morphism section card +interface SettingsSectionProps { + title: string; + description?: string; + icon?: SettingsSectionIconName; + iconColor?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; + variant?: "default" | "highlight" | "warning"; +} + +export function SettingsSection({ + title, + description, + icon, + iconColor = "text-primary", + actions, + children, + className, + variant = "default", +}: SettingsSectionProps) { + const variantStyles = { + default: "bg-card/30 border-white/5 hover:border-white/10", + highlight: "bg-primary/5 border-primary/20 hover:border-primary/30", + warning: "bg-yellow-500/5 border-yellow-500/20 hover:border-yellow-500/30", + }; + + const Icon = icon ? SETTINGS_SECTION_ICON_MAP[icon] : null; + + return ( + + {/* Decorative gradient blob */} + {variant === "highlight" && ( +
+ )} + +
+
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ {actions &&
{actions}
} +
+ {children} +
+ + ); +} + +// Toggle row component +interface SettingsToggleRowProps { + title: string; + description?: string; + icon?: LucideIcon; + iconBgColor?: string; + iconColor?: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + +export function SettingsToggleRow({ + title, + description, + icon: Icon, + iconBgColor = "bg-primary/10", + iconColor = "text-primary", + checked, + onCheckedChange, + disabled, +}: SettingsToggleRowProps) { + return ( +
!disabled && onCheckedChange(!checked)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + !disabled && onCheckedChange(!checked); + } + }} + role="switch" + aria-checked={checked} + tabIndex={disabled ? -1 : 0} + > +
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+