From 4dad830cd2365541a7fb69412a1769728141368b Mon Sep 17 00:00:00 2001
From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com>
Date: Tue, 13 Jan 2026 23:47:34 +0800
Subject: [PATCH 01/24] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=20usage-doc=20/?=
=?UTF-8?q?=20big-screen=20i18n=20=E7=A1=AC=E7=BC=96=E7=A0=81=20+=20?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Next.js=20params=20Promise=20=E6=8A=A5?=
=?UTF-8?q?=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en/bigScreen.json | 2 +
messages/en/usage.json | 33 +++++-
messages/ja/bigScreen.json | 2 +
messages/ja/usage.json | 33 +++++-
messages/ru/bigScreen.json | 2 +
messages/ru/usage.json | 33 +++++-
messages/zh-CN/bigScreen.json | 2 +
messages/zh-CN/usage.json | 33 +++++-
messages/zh-TW/bigScreen.json | 2 +
messages/zh-TW/usage.json | 33 +++++-
.../internal/dashboard/big-screen/layout.tsx | 19 +++-
src/app/[locale]/usage-doc/layout.tsx | 39 +++++--
src/app/[locale]/usage-doc/page.tsx | 75 ++++++-------
.../i18n/big-screen-metadata-keys.test.ts | 18 ++++
.../unit/nextjs/async-params-layouts.test.tsx | 101 ++++++++++++++++++
...del-multi-select-custom-models-ui.test.tsx | 16 ++-
.../usage-doc/opencode-usage-doc.test.tsx | 18 ++++
tests/unit/usage-doc/usage-doc-page.test.tsx | 14 +++
18 files changed, 416 insertions(+), 59 deletions(-)
create mode 100644 tests/unit/i18n/big-screen-metadata-keys.test.ts
create mode 100644 tests/unit/nextjs/async-params-layouts.test.tsx
diff --git a/messages/en/bigScreen.json b/messages/en/bigScreen.json
index a83b29fd8..a6eeadd56 100644
--- a/messages/en/bigScreen.json
+++ b/messages/en/bigScreen.json
@@ -1,4 +1,6 @@
{
+ "pageTitle": "Realtime big screen - Claude Code Hub",
+ "pageDescription": "Claude Code Hub realtime monitoring big screen",
"title": "CLAUDE CODE HUB",
"subtitle": "REALTIME DATA MONITOR",
"metrics": {
diff --git a/messages/en/usage.json b/messages/en/usage.json
index 3eeae3206..c022cc62c 100644
--- a/messages/en/usage.json
+++ b/messages/en/usage.json
@@ -19,7 +19,7 @@
},
"codeExamples": {
- "label": "Code example - ${language}",
+ "label": "Code example - {language}",
"description": "Click the code block to copy to clipboard"
},
@@ -206,7 +206,7 @@
"unix": {
"temporary": "Temporary setting (current session):",
"permanent": "Permanent setting:",
- "permanentNote": "Add to your shell configuration file (${shellConfig}):"
+ "permanentNote": "Add to your shell configuration file ({shellConfig}):"
}
},
@@ -698,6 +698,35 @@
"linux": "Linux"
},
+ "placeholders": {
+ "windowsUserName": "your-username",
+ "shellConfig": {
+ "linux": "~/.bashrc",
+ "macos": "~/.zshrc"
+ },
+ "codexVsCodeConfigFiles": "config.toml and auth.json"
+ },
+
+ "snippets": {
+ "comments": {
+ "updateHomebrew": "# Update Homebrew",
+ "installNodeJs": "# Install Node.js",
+ "usingChocolatey": "# Using Chocolatey",
+ "orUsingScoop": "# Or using Scoop",
+ "addNodeSourceRepo": "# Add NodeSource repository",
+ "ubuntuDebian": "# Ubuntu/Debian",
+ "centosRhelFedora": "# CentOS/RHEL/Fedora",
+ "addToPathIfMissing": "# Add to PATH (if missing)",
+ "checkEnvVar": "# Check environment variable",
+ "testNetworkConnection": "# Test network connection"
+ }
+ },
+
+ "layout": {
+ "headerTitle": "Usage Docs",
+ "loginConsole": "Log in to console"
+ },
+
"ui": {
"mainContent": "Documentation content",
"main": "main",
diff --git a/messages/ja/bigScreen.json b/messages/ja/bigScreen.json
index 7973014f7..6ecfd9d74 100644
--- a/messages/ja/bigScreen.json
+++ b/messages/ja/bigScreen.json
@@ -1,4 +1,6 @@
{
+ "pageTitle": "リアルタイム大画面 - Claude Code Hub",
+ "pageDescription": "Claude Code Hub のリアルタイム監視ダッシュボード",
"title": "CLAUDE CODE HUB",
"subtitle": "リアルタイムデータモニター",
"metrics": {
diff --git a/messages/ja/usage.json b/messages/ja/usage.json
index a83e9ed36..e72c8dffb 100644
--- a/messages/ja/usage.json
+++ b/messages/ja/usage.json
@@ -19,7 +19,7 @@
},
"codeExamples": {
- "label": "コード例 - ${language}",
+ "label": "コード例 - {language}",
"description": "コードブロックをクリックしてクリップボードにコピー"
},
@@ -206,7 +206,7 @@
"unix": {
"temporary": "一時設定 (現在のセッション):",
"permanent": "永続設定:",
- "permanentNote": "シェル設定ファイル (${shellConfig}) に追加:"
+ "permanentNote": "シェル設定ファイル ({shellConfig}) に追加:"
}
},
@@ -698,6 +698,35 @@
"linux": "Linux"
},
+ "placeholders": {
+ "windowsUserName": "your-username",
+ "shellConfig": {
+ "linux": "~/.bashrc",
+ "macos": "~/.zshrc"
+ },
+ "codexVsCodeConfigFiles": "config.toml と auth.json"
+ },
+
+ "snippets": {
+ "comments": {
+ "updateHomebrew": "# Homebrew を更新",
+ "installNodeJs": "# Node.js をインストール",
+ "usingChocolatey": "# Chocolatey を使用",
+ "orUsingScoop": "# または Scoop を使用",
+ "addNodeSourceRepo": "# NodeSource リポジトリを追加",
+ "ubuntuDebian": "# Ubuntu/Debian",
+ "centosRhelFedora": "# CentOS/RHEL/Fedora",
+ "addToPathIfMissing": "# PATH に追加(未設定の場合)",
+ "checkEnvVar": "# 環境変数を確認",
+ "testNetworkConnection": "# ネットワーク接続を確認"
+ }
+ },
+
+ "layout": {
+ "headerTitle": "利用ドキュメント",
+ "loginConsole": "コンソールにログイン"
+ },
+
"ui": {
"mainContent": "ドキュメントコンテンツ",
"main": "main",
diff --git a/messages/ru/bigScreen.json b/messages/ru/bigScreen.json
index f6be0fbba..33b2a9090 100644
--- a/messages/ru/bigScreen.json
+++ b/messages/ru/bigScreen.json
@@ -1,4 +1,6 @@
{
+ "pageTitle": "Панель реального времени - Claude Code Hub",
+ "pageDescription": "Большой экран мониторинга в реальном времени Claude Code Hub",
"title": "CLAUDE CODE HUB",
"subtitle": "МОНИТОР ДАННЫХ В РЕАЛЬНОМ ВРЕМЕНИ",
"metrics": {
diff --git a/messages/ru/usage.json b/messages/ru/usage.json
index 067ee5d2c..babe42cc5 100644
--- a/messages/ru/usage.json
+++ b/messages/ru/usage.json
@@ -19,7 +19,7 @@
},
"codeExamples": {
- "label": "Пример кода - ${language}",
+ "label": "Пример кода - {language}",
"description": "Нажмите на блок кода для копирования в буфер обмена"
},
@@ -206,7 +206,7 @@
"unix": {
"temporary": "Временная настройка (текущий сеанс):",
"permanent": "Постоянная настройка:",
- "permanentNote": "Добавьте в файл конфигурации оболочки (${shellConfig}):"
+ "permanentNote": "Добавьте в файл конфигурации оболочки ({shellConfig}):"
}
},
@@ -698,6 +698,35 @@
"linux": "Linux"
},
+ "placeholders": {
+ "windowsUserName": "your-username",
+ "shellConfig": {
+ "linux": "~/.bashrc",
+ "macos": "~/.zshrc"
+ },
+ "codexVsCodeConfigFiles": "config.toml и auth.json"
+ },
+
+ "snippets": {
+ "comments": {
+ "updateHomebrew": "# Обновить Homebrew",
+ "installNodeJs": "# Установить Node.js",
+ "usingChocolatey": "# Используя Chocolatey",
+ "orUsingScoop": "# Или через Scoop",
+ "addNodeSourceRepo": "# Добавить репозиторий NodeSource",
+ "ubuntuDebian": "# Ubuntu/Debian",
+ "centosRhelFedora": "# CentOS/RHEL/Fedora",
+ "addToPathIfMissing": "# Добавить в PATH (если отсутствует)",
+ "checkEnvVar": "# Проверить переменную окружения",
+ "testNetworkConnection": "# Проверить сетевое подключение"
+ }
+ },
+
+ "layout": {
+ "headerTitle": "Документация",
+ "loginConsole": "Войти в консоль"
+ },
+
"ui": {
"mainContent": "Содержимое документации",
"main": "main",
diff --git a/messages/zh-CN/bigScreen.json b/messages/zh-CN/bigScreen.json
index 16a75d358..c750dce83 100644
--- a/messages/zh-CN/bigScreen.json
+++ b/messages/zh-CN/bigScreen.json
@@ -1,4 +1,6 @@
{
+ "pageTitle": "实时数据大屏 - Claude Code Hub",
+ "pageDescription": "Claude Code Hub 实时监控数据大屏",
"title": "CLAUDE CODE HUB",
"subtitle": "实时数据监控中台",
"metrics": {
diff --git a/messages/zh-CN/usage.json b/messages/zh-CN/usage.json
index b25fc042c..4bd2c86ff 100644
--- a/messages/zh-CN/usage.json
+++ b/messages/zh-CN/usage.json
@@ -19,7 +19,7 @@
},
"codeExamples": {
- "label": "代码示例 - ${language}",
+ "label": "代码示例 - {language}",
"description": "点击代码块可复制到剪贴板"
},
@@ -202,7 +202,7 @@
"unix": {
"temporary": "临时设置(当前会话):",
"permanent": "永久设置:",
- "permanentNote": "添加到您的 shell 配置文件(${shellConfig}):"
+ "permanentNote": "添加到您的 shell 配置文件({shellConfig}):"
}
},
@@ -694,6 +694,35 @@
"linux": "Linux"
},
+ "placeholders": {
+ "windowsUserName": "你的用户名",
+ "shellConfig": {
+ "linux": "~/.bashrc",
+ "macos": "~/.zshrc"
+ },
+ "codexVsCodeConfigFiles": "config.toml 和 auth.json"
+ },
+
+ "snippets": {
+ "comments": {
+ "updateHomebrew": "# 更新 Homebrew",
+ "installNodeJs": "# 安装 Node.js",
+ "usingChocolatey": "# 使用 Chocolatey",
+ "orUsingScoop": "# 或使用 Scoop",
+ "addNodeSourceRepo": "# 添加 NodeSource 仓库",
+ "ubuntuDebian": "# Ubuntu/Debian",
+ "centosRhelFedora": "# CentOS/RHEL/Fedora",
+ "addToPathIfMissing": "# 添加到 PATH(如果不在)",
+ "checkEnvVar": "# 检查环境变量",
+ "testNetworkConnection": "# 测试网络连接"
+ }
+ },
+
+ "layout": {
+ "headerTitle": "使用文档",
+ "loginConsole": "登录控制台"
+ },
+
"ui": {
"mainContent": "文档内容",
"main": "main",
diff --git a/messages/zh-TW/bigScreen.json b/messages/zh-TW/bigScreen.json
index 6d92fbd4b..9f256e205 100644
--- a/messages/zh-TW/bigScreen.json
+++ b/messages/zh-TW/bigScreen.json
@@ -1,4 +1,6 @@
{
+ "pageTitle": "即時資料大屏 - Claude Code Hub",
+ "pageDescription": "Claude Code Hub 即時監控資料大屏",
"title": "CLAUDE CODE HUB",
"subtitle": "即時資料監控中台",
"metrics": {
diff --git a/messages/zh-TW/usage.json b/messages/zh-TW/usage.json
index b12c046fb..ec5c9afc5 100644
--- a/messages/zh-TW/usage.json
+++ b/messages/zh-TW/usage.json
@@ -19,7 +19,7 @@
},
"codeExamples": {
- "label": "代碼示例 - ${language}",
+ "label": "代碼示例 - {language}",
"description": "點擊代碼塊可複製到剪貼板"
},
@@ -202,7 +202,7 @@
"unix": {
"temporary": "臨時設置(當前會話):",
"permanent": "永久設置:",
- "permanentNote": "添加到您的 shell 配置文件(${shellConfig}):"
+ "permanentNote": "添加到您的 shell 配置文件({shellConfig}):"
}
},
@@ -694,6 +694,35 @@
"linux": "Linux"
},
+ "placeholders": {
+ "windowsUserName": "你的用戶名",
+ "shellConfig": {
+ "linux": "~/.bashrc",
+ "macos": "~/.zshrc"
+ },
+ "codexVsCodeConfigFiles": "config.toml 和 auth.json"
+ },
+
+ "snippets": {
+ "comments": {
+ "updateHomebrew": "# 更新 Homebrew",
+ "installNodeJs": "# 安裝 Node.js",
+ "usingChocolatey": "# 使用 Chocolatey",
+ "orUsingScoop": "# 或使用 Scoop",
+ "addNodeSourceRepo": "# 添加 NodeSource 倉庫",
+ "ubuntuDebian": "# Ubuntu/Debian",
+ "centosRhelFedora": "# CentOS/RHEL/Fedora",
+ "addToPathIfMissing": "# 添加到 PATH(如果不在)",
+ "checkEnvVar": "# 檢查環境變數",
+ "testNetworkConnection": "# 測試網路連線"
+ }
+ },
+
+ "layout": {
+ "headerTitle": "使用文檔",
+ "loginConsole": "登入控制台"
+ },
+
"ui": {
"mainContent": "文檔內容",
"main": "main",
diff --git a/src/app/[locale]/internal/dashboard/big-screen/layout.tsx b/src/app/[locale]/internal/dashboard/big-screen/layout.tsx
index a82329010..e1b090446 100644
--- a/src/app/[locale]/internal/dashboard/big-screen/layout.tsx
+++ b/src/app/[locale]/internal/dashboard/big-screen/layout.tsx
@@ -1,9 +1,20 @@
import type { Metadata } from "next";
+import { getTranslations } from "next-intl/server";
-export const metadata: Metadata = {
- title: "实时数据大屏 - Claude Code Hub",
- description: "Claude Code Hub 实时监控数据大屏",
-};
+type BigScreenParams = { locale: string };
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise | BigScreenParams;
+}): Promise {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "bigScreen" });
+ return {
+ title: t("pageTitle"),
+ description: t("pageDescription"),
+ };
+}
export default function BigScreenLayout({ children }: { children: React.ReactNode }) {
// 全屏布局,移除所有导航栏、侧边栏等元素
diff --git a/src/app/[locale]/usage-doc/layout.tsx b/src/app/[locale]/usage-doc/layout.tsx
index 7cdcbfe79..20572674e 100644
--- a/src/app/[locale]/usage-doc/layout.tsx
+++ b/src/app/[locale]/usage-doc/layout.tsx
@@ -1,21 +1,44 @@
import { Book, LogIn } from "lucide-react";
import type { Metadata } from "next";
+import { getTranslations } from "next-intl/server";
+import { cache } from "react";
import { Link } from "@/i18n/routing";
import { getSession } from "@/lib/auth";
import { DashboardHeader } from "../dashboard/_components/dashboard-header";
-export const metadata: Metadata = {
- title: "使用文档 - Claude Code Hub",
- description: "Claude Code Hub API 代理服务使用文档和指南",
-};
+type UsageDocParams = { locale: string };
+
+const getUsageTranslations = cache((locale: string) =>
+ getTranslations({ locale, namespace: "usage" })
+);
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise | UsageDocParams;
+}): Promise {
+ const { locale } = await params;
+ const t = await getUsageTranslations(locale);
+ return {
+ title: t("pageTitle"),
+ description: t("pageDescription"),
+ };
+}
/**
* 文档页面布局
* 提供文档页面的容器、样式和共用头部
* 支持未登录访问:未登录时显示简化版头部,已登录时显示完整的 DashboardHeader
*/
-export default async function UsageDocLayout({ children }: { children: React.ReactNode }) {
- const session = await getSession();
+export default async function UsageDocLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise | UsageDocParams;
+}) {
+ const { locale } = await params;
+ const [session, t] = await Promise.all([getSession(), getUsageTranslations(locale)]);
return (
@@ -27,14 +50,14 @@ export default async function UsageDocLayout({ children }: { children: React.Rea
- 使用文档
+ {t("layout.headerTitle")}
- 登录控制台
+ {t("layout.loginConsole")}
diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx
index bd5ddc9d5..ee6a2f6d0 100644
--- a/src/app/[locale]/usage-doc/page.tsx
+++ b/src/app/[locale]/usage-doc/page.tsx
@@ -82,7 +82,7 @@ function getCLIConfigs(t: (key: string) => string): Record
{
configFile: "config.json",
configPath: {
macos: "~/.claude",
- windows: "C:\\Users\\你的用户名\\.claude",
+ windows: `C:\\Users\\${t("placeholders.windowsUserName")}\\.claude`,
linux: "~/.claude",
},
},
@@ -95,10 +95,10 @@ function getCLIConfigs(t: (key: string) => string): Record {
requiresNodeJs: true,
vsCodeExtension: {
name: "Codex – OpenAI's coding agent",
- configFile: "config.toml 和 auth.json",
+ configFile: t("placeholders.codexVsCodeConfigFiles"),
configPath: {
macos: "~/.codex",
- windows: "C:\\Users\\你的用户名\\.codex",
+ windows: `C:\\Users\\${t("placeholders.windowsUserName")}\\.codex`,
linux: "~/.codex",
},
},
@@ -150,9 +150,9 @@ export function UsageDocContent({ origin }: UsageDocContentProps) {
{t("claudeCode.environmentSetup.macos.homebrew")}
{t("claudeCode.environmentSetup.macos.official")}
@@ -197,10 +197,10 @@ brew install node`}
@@ -218,9 +218,9 @@ scoop install nodejs`}
{t("claudeCode.environmentSetup.linux.official")}
@@ -228,11 +228,11 @@ sudo apt-get install -y nodejs`}
@@ -308,14 +308,9 @@ npm --version`}
@@ -454,15 +449,22 @@ curl -fsSL https://claude.ai/install.sh | bash -s 1.0.58`}
* 渲染 Claude Code 配置
*/
const renderClaudeCodeConfiguration = (os: OS) => {
+ const windowsUserName = t("placeholders.windowsUserName");
const configPath =
os === "windows"
- ? "C:\\Users\\你的用户名\\.claude\\settings.json"
+ ? `C:\\Users\\${windowsUserName}\\.claude\\settings.json`
: "~/.claude/settings.json";
+ const shellConfigFile =
+ os === "linux"
+ ? t("placeholders.shellConfig.linux")
+ : os === "macos"
+ ? t("placeholders.shellConfig.macos")
+ : "";
const shellConfig =
os === "linux"
- ? "~/.bashrc 或 ~/.zshrc"
+ ? t("placeholders.shellConfig.linux")
: os === "macos"
- ? "~/.zshrc 或 ~/.bash_profile"
+ ? t("placeholders.shellConfig.macos")
: "";
return (
@@ -546,9 +548,9 @@ export ANTHROPIC_AUTH_TOKEN="your-api-key-here"`}
> ${shellConfig.split(" ")[0]}
-echo 'export ANTHROPIC_AUTH_TOKEN="your-api-key-here"' >> ${shellConfig.split(" ")[0]}
-source ${shellConfig.split(" ")[0]}`}
+ code={`echo 'export ANTHROPIC_BASE_URL="${resolvedOrigin}"' >> ${shellConfigFile}
+echo 'export ANTHROPIC_AUTH_TOKEN="your-api-key-here"' >> ${shellConfigFile}
+source ${shellConfigFile}`}
/>
>
)}
@@ -623,12 +625,13 @@ sk_xxxxxxxxxxxxxxxxxx`}
* 渲染 Codex 配置
*/
const renderCodexConfiguration = (os: OS) => {
- const configPath = os === "windows" ? "C:\\Users\\你的用户名\\.codex" : "~/.codex";
- const shellConfig =
+ const windowsUserName = t("placeholders.windowsUserName");
+ const configPath = os === "windows" ? `C:\\Users\\${windowsUserName}\\.codex` : "~/.codex";
+ const shellConfigFile =
os === "linux"
- ? "~/.bashrc 或 ~/.zshrc"
+ ? t("placeholders.shellConfig.linux")
: os === "macos"
- ? "~/.zshrc 或 ~/.bash_profile"
+ ? t("placeholders.shellConfig.macos")
: "";
return (
@@ -742,8 +745,8 @@ network_access = true`}
{t("codex.configuration.envVars.unix.instruction")}
> ${shellConfig.split(" ")[0]}
-source ${shellConfig.split(" ")[0]}`}
+ code={`echo 'export CCH_API_KEY="your-api-key-here"' >> ${shellConfigFile}
+source ${shellConfigFile}`}
/>
>
)}
@@ -1475,7 +1478,7 @@ ${cli.cliName}`}
code={`# ${t(cmdNotFoundUnixKey)}
npm config get prefix
-# 添加到 PATH(如果不在)
+${t("snippets.comments.addToPathIfMissing")}
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.${os === "macos" ? "zshrc" : "bashrc"}
source ~/.${os === "macos" ? "zshrc" : "bashrc"}`}
/>
@@ -1497,19 +1500,19 @@ source ~/.${os === "macos" ? "zshrc" : "bashrc"}`}
) : os === "windows" ? (
) : (
)}
diff --git a/tests/unit/i18n/big-screen-metadata-keys.test.ts b/tests/unit/i18n/big-screen-metadata-keys.test.ts
new file mode 100644
index 000000000..ab84a9c34
--- /dev/null
+++ b/tests/unit/i18n/big-screen-metadata-keys.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "vitest";
+
+import enBigScreen from "../../../messages/en/bigScreen.json";
+import jaBigScreen from "../../../messages/ja/bigScreen.json";
+import ruBigScreen from "../../../messages/ru/bigScreen.json";
+import zhCNBigScreen from "../../../messages/zh-CN/bigScreen.json";
+import zhTWBigScreen from "../../../messages/zh-TW/bigScreen.json";
+
+describe("messages//bigScreen metadata keys", () => {
+ test("provides pageTitle/pageDescription", () => {
+ const all = [enBigScreen, jaBigScreen, ruBigScreen, zhCNBigScreen, zhTWBigScreen];
+
+ for (const bigScreen of all) {
+ expect(bigScreen).toHaveProperty("pageTitle");
+ expect(bigScreen).toHaveProperty("pageDescription");
+ }
+ });
+});
diff --git a/tests/unit/nextjs/async-params-layouts.test.tsx b/tests/unit/nextjs/async-params-layouts.test.tsx
new file mode 100644
index 000000000..cb2d1f3fb
--- /dev/null
+++ b/tests/unit/nextjs/async-params-layouts.test.tsx
@@ -0,0 +1,101 @@
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const authMocks = vi.hoisted(() => ({
+ getSession: vi.fn(async () => null),
+}));
+
+vi.mock("@/lib/auth", () => authMocks);
+
+vi.mock("@/i18n/routing", () => ({
+ Link: ({
+ href,
+ children,
+ ...rest
+ }: {
+ href: string;
+ children: ReactNode;
+ className?: string;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+const intlServerMocks = vi.hoisted(() => ({
+ getTranslations: vi.fn(async ({ locale, namespace }: { locale: string; namespace: string }) => {
+ return (key: string) => `${namespace}.${key}.${locale}`;
+ }),
+}));
+
+vi.mock("next-intl/server", () => intlServerMocks);
+
+function makeAsyncParams(locale: string) {
+ const promise = Promise.resolve({ locale });
+
+ Object.defineProperty(promise, "locale", {
+ get() {
+ throw new Error("sync access to params.locale is not allowed");
+ },
+ });
+
+ return promise as Promise<{ locale: string }> & { locale: string };
+}
+
+describe("Next.js async params compatibility", () => {
+ beforeEach(() => {
+ authMocks.getSession.mockReset();
+ authMocks.getSession.mockResolvedValue(null);
+ intlServerMocks.getTranslations.mockClear();
+ });
+
+ test("usage-doc generateMetadata awaits params before reading locale", async () => {
+ const { generateMetadata } = await import("@/app/[locale]/usage-doc/layout");
+
+ const metadata = await generateMetadata({
+ params: makeAsyncParams("en") as unknown as Promise<{ locale: string }>,
+ });
+
+ expect(metadata).toEqual({
+ title: "usage.pageTitle.en",
+ description: "usage.pageDescription.en",
+ });
+ });
+
+ test("UsageDocLayout awaits params before reading locale (session/no-session branches)", async () => {
+ const UsageDocLayoutModule = await import("@/app/[locale]/usage-doc/layout");
+
+ authMocks.getSession.mockResolvedValueOnce(null);
+ const noSession = await UsageDocLayoutModule.default({
+ children:
,
+ params: makeAsyncParams("en") as unknown as Promise<{ locale: string }>,
+ });
+ expect(noSession).toBeTruthy();
+
+ authMocks.getSession.mockResolvedValueOnce({} as never);
+ const hasSession = await UsageDocLayoutModule.default({
+ children:
,
+ params: makeAsyncParams("en") as unknown as Promise<{ locale: string }>,
+ });
+ expect(hasSession).toBeTruthy();
+ });
+
+ test("big-screen generateMetadata awaits params before reading locale", async () => {
+ const BigScreenLayoutModule = await import(
+ "@/app/[locale]/internal/dashboard/big-screen/layout"
+ );
+
+ const metadata = await BigScreenLayoutModule.generateMetadata({
+ params: makeAsyncParams("en") as unknown as Promise<{ locale: string }>,
+ });
+
+ expect(metadata).toEqual({
+ title: "bigScreen.pageTitle.en",
+ description: "bigScreen.pageDescription.en",
+ });
+
+ const element = BigScreenLayoutModule.default({ children:
});
+ expect(element).toBeTruthy();
+ });
+});
diff --git a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx
index dc19c6357..0bdcb80cb 100644
--- a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx
+++ b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx
@@ -8,7 +8,11 @@ import { createRoot } from "react-dom/client";
import { NextIntlClientProvider } from "next-intl";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ModelMultiSelect } from "@/app/[locale]/settings/providers/_components/model-multi-select";
-import { loadMessages as loadTestMessages } from "../prices/test-messages";
+import commonMessages from "../../../../messages/en/common.json";
+import errorsMessages from "../../../../messages/en/errors.json";
+import formsMessages from "../../../../messages/en/forms.json";
+import settingsMessages from "../../../../messages/en/settings";
+import uiMessages from "../../../../messages/en/ui.json";
const modelPricesActionMocks = vi.hoisted(() => ({
getAvailableModelsByProviderType: vi.fn(async () => ["remote-model-1"]),
@@ -21,6 +25,16 @@ const providersActionMocks = vi.hoisted(() => ({
}));
vi.mock("@/actions/providers", () => providersActionMocks);
+function loadMessages() {
+ return {
+ common: commonMessages,
+ errors: errorsMessages,
+ ui: uiMessages,
+ forms: formsMessages,
+ settings: settingsMessages,
+ };
+}
+
function render(node: ReactNode) {
const container = document.createElement("div");
document.body.appendChild(container);
diff --git a/tests/unit/usage-doc/opencode-usage-doc.test.tsx b/tests/unit/usage-doc/opencode-usage-doc.test.tsx
index d3239ca84..7c09f40cc 100644
--- a/tests/unit/usage-doc/opencode-usage-doc.test.tsx
+++ b/tests/unit/usage-doc/opencode-usage-doc.test.tsx
@@ -132,6 +132,24 @@ describe("UsageDoc - OpenCode 配置教程", () => {
expect(usageMessages).toHaveProperty("opencode.configuration.title");
expect(usageMessages).toHaveProperty("opencode.startup.title");
expect(usageMessages).toHaveProperty("opencode.commonIssues.title");
+
+ expect(usageMessages).toHaveProperty("layout.headerTitle");
+ expect(usageMessages).toHaveProperty("layout.loginConsole");
+
+ expect(usageMessages).toHaveProperty("placeholders.windowsUserName");
+ expect(usageMessages).toHaveProperty("placeholders.shellConfig.linux");
+ expect(usageMessages).toHaveProperty("placeholders.shellConfig.macos");
+ expect(usageMessages).toHaveProperty("placeholders.codexVsCodeConfigFiles");
+
+ expect(usageMessages).toHaveProperty("claudeCode.installation.nativeInstall.macos.curls");
+
+ expect(usageMessages).toHaveProperty("snippets.comments.updateHomebrew");
+ expect(usageMessages).toHaveProperty("snippets.comments.installNodeJs");
+ expect(usageMessages).toHaveProperty("snippets.comments.ubuntuDebian");
+ expect(usageMessages).toHaveProperty("snippets.comments.centosRhelFedora");
+ expect(usageMessages).toHaveProperty("snippets.comments.addToPathIfMissing");
+ expect(usageMessages).toHaveProperty("snippets.comments.checkEnvVar");
+ expect(usageMessages).toHaveProperty("snippets.comments.testNetworkConnection");
}
});
});
diff --git a/tests/unit/usage-doc/usage-doc-page.test.tsx b/tests/unit/usage-doc/usage-doc-page.test.tsx
index 055b578ed..284637e53 100644
--- a/tests/unit/usage-doc/usage-doc-page.test.tsx
+++ b/tests/unit/usage-doc/usage-doc-page.test.tsx
@@ -80,6 +80,20 @@ describe("UsageDocPage - 目录/快速链接交互", () => {
Reflect.deleteProperty(document, "cookie");
});
+ test("ru 语言不应显示中文占位符与代码块注释", async () => {
+ const { unmount } = await renderWithIntl("ru", );
+
+ const text = document.body.textContent || "";
+
+ expect(text).not.toContain("你的用户名");
+ expect(text).not.toContain("检查环境变量");
+ expect(text).not.toContain("添加到 PATH");
+
+ expect(text).toContain("C:\\Users\\your-username");
+
+ await unmount();
+ });
+
test("目录项点击后应触发平滑滚动", async () => {
const scrollToMock = vi.fn();
Object.defineProperty(window, "scrollTo", {
From 84c8ce6f0337af1267647caf5cd27efc88572cc7 Mon Sep 17 00:00:00 2001
From: Ding <44717411+ding113@users.noreply.github.com>
Date: Wed, 14 Jan 2026 01:13:24 +0800
Subject: [PATCH 02/24] feat(leaderboard): add user tag and group filters for
user ranking (#607)
* feat(leaderboard): add user tag and group filters for user ranking (#606)
Add filtering capability to the leaderboard user ranking by:
- userTags: filter users by their tags (OR logic)
- userGroups: filter users by their providerGroup (OR logic)
Changes:
- Repository: Add UserLeaderboardFilters interface and SQL filtering
- Cache: Extend LeaderboardFilters and include filters in cache key
- API: Parse userTags/userGroups query params (CSV format, max 20)
- Frontend: Add TagInput filters (admin-only, user scope only)
- i18n: Add translation keys for 5 languages
Closes #606
* refactor: apply reviewer suggestions for leaderboard filters
- Use JSONB ? operator instead of @> for better performance
- Extract parseListParam helper to reduce code duplication
---
messages/en/dashboard.json | 4 +
messages/ja/dashboard.json | 4 +
messages/ru/dashboard.json | 4 +
messages/zh-CN/dashboard.json | 4 +
messages/zh-TW/dashboard.json | 4 +
.../_components/leaderboard-view.tsx | 38 ++++++++-
src/app/api/leaderboard/route.ts | 23 +++++-
src/lib/redis/leaderboard-cache.ts | 41 +++++++---
src/repository/leaderboard.ts | 81 ++++++++++++++-----
9 files changed, 172 insertions(+), 31 deletions(-)
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 91ef7163a..e752d461b 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -322,6 +322,10 @@
"adminAction": "Enable this permission.",
"userAction": "Please contact an administrator to enable this permission.",
"systemSettings": "System Settings"
+ },
+ "filters": {
+ "userTagsPlaceholder": "Filter by user tags...",
+ "userGroupsPlaceholder": "Filter by user groups..."
}
},
"sessions": {
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index bc0467dc5..030f0868c 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -321,6 +321,10 @@
"adminAction": "この権限を有効にします。",
"userAction": "この権限を有効にするには、管理者に連絡してください。",
"systemSettings": "システム設定"
+ },
+ "filters": {
+ "userTagsPlaceholder": "ユーザータグでフィルタ...",
+ "userGroupsPlaceholder": "ユーザーグループでフィルタ..."
}
},
"sessions": {
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index 1b332bc7e..e3d6e9f5b 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -321,6 +321,10 @@
"adminAction": "Включить это разрешение.",
"userAction": "Пожалуйста, свяжитесь с администратором, чтобы включить это разрешение.",
"systemSettings": "Настройки системы"
+ },
+ "filters": {
+ "userTagsPlaceholder": "Фильтр по тегам пользователей...",
+ "userGroupsPlaceholder": "Фильтр по группам пользователей..."
}
},
"sessions": {
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index 40b1be77a..0df593e25 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -322,6 +322,10 @@
"adminAction": "开启此权限。",
"userAction": "请联系管理员开启此权限。",
"systemSettings": "系统设置"
+ },
+ "filters": {
+ "userTagsPlaceholder": "按用户标签筛选...",
+ "userGroupsPlaceholder": "按用户分组筛选..."
}
},
"sessions": {
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index 7fb6ace8b..cff75d534 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -322,6 +322,10 @@
"adminAction": "開啟此權限。",
"userAction": "請聯繫管理員開啟此權限。",
"systemSettings": "系統設定"
+ },
+ "filters": {
+ "userTagsPlaceholder": "按使用者標籤篩選...",
+ "userGroupsPlaceholder": "按使用者群組篩選..."
}
},
"sessions": {
diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
index 89314fd40..4cd1f6ef6 100644
--- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
+++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
@@ -7,6 +7,7 @@ import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_component
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { TagInput } from "@/components/ui/tag-input";
import { formatTokenAmount } from "@/lib/utils";
import type {
DateRangeParams,
@@ -51,6 +52,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
const [period, setPeriod] = useState(initialPeriod);
const [dateRange, setDateRange] = useState(undefined);
const [providerTypeFilter, setProviderTypeFilter] = useState("all");
+ const [userTagFilters, setUserTagFilters] = useState([]);
+ const [userGroupFilters, setUserGroupFilters] = useState([]);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -96,6 +99,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
) {
url += `&providerType=${encodeURIComponent(providerTypeFilter)}`;
}
+ if (scope === "user") {
+ if (userTagFilters.length > 0) {
+ url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`;
+ }
+ if (userGroupFilters.length > 0) {
+ url += `&userGroups=${encodeURIComponent(userGroupFilters.join(","))}`;
+ }
+ }
const res = await fetch(url);
if (!res.ok) {
@@ -120,7 +131,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
return () => {
cancelled = true;
};
- }, [scope, period, dateRange, providerTypeFilter, t]);
+ }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]);
const handlePeriodChange = useCallback(
(newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => {
@@ -369,6 +380,31 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
) : null}
+ {scope === "user" && isAdmin && (
+
+ )}
+
{/* Date range picker with quick period buttons */}
{
+ if (!param) return undefined;
+ const items = param
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ .slice(0, 20);
+ return items.length > 0 ? items : undefined;
+ };
+
+ let userTags: string[] | undefined;
+ let userGroups: string[] | undefined;
+ if (scope === "user") {
+ userTags = parseListParam(userTagsParam);
+ userGroups = parseListParam(userGroupsParam);
+ }
+
// 使用 Redis 乐观缓存获取数据
const rawData = await getLeaderboardWithCache(
period,
systemSettings.currencyDisplay,
scope,
dateRange,
- providerType ? { providerType } : undefined
+ { providerType, userTags, userGroups }
);
// 格式化金额字段
@@ -162,6 +181,8 @@ export async function GET(request: NextRequest) {
scope,
dateRange,
providerType,
+ userTags,
+ userGroups,
entriesCount: data.length,
});
diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts
index fb6e55428..d114d0206 100644
--- a/src/lib/redis/leaderboard-cache.ts
+++ b/src/lib/redis/leaderboard-cache.ts
@@ -28,6 +28,7 @@ import {
type ModelLeaderboardEntry,
type ProviderCacheHitRateLeaderboardEntry,
type ProviderLeaderboardEntry,
+ type UserLeaderboardFilters,
} from "@/repository/leaderboard";
import type { ProviderType } from "@/types/provider";
import { getRedisClient } from "./client";
@@ -43,6 +44,8 @@ type LeaderboardData =
export interface LeaderboardFilters {
providerType?: ProviderType;
+ userTags?: string[];
+ userGroups?: string[];
}
/**
@@ -59,24 +62,35 @@ function buildCacheKey(
const tz = getEnvConfig().TZ; // ensure date formatting aligns with configured timezone
const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : "";
+ let userFilterSuffix = "";
+ if (scope === "user") {
+ const tagsPart = filters?.userTags?.length
+ ? `:tags:${[...filters.userTags].sort().join(",")}`
+ : "";
+ const groupsPart = filters?.userGroups?.length
+ ? `:groups:${[...filters.userGroups].sort().join(",")}`
+ : "";
+ userFilterSuffix = tagsPart + groupsPart;
+ }
+
if (period === "custom" && dateRange) {
// leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD
- return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}`;
+ return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
} else if (period === "daily") {
// leaderboard:{scope}:daily:2025-01-15:USD
const dateStr = formatInTimeZone(now, tz, "yyyy-MM-dd");
- return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}`;
+ return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
} else if (period === "weekly") {
// leaderboard:{scope}:weekly:2025-W03:USD (ISO week)
const weekStr = formatInTimeZone(now, tz, "yyyy-'W'ww");
- return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}`;
+ return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
} else if (period === "monthly") {
// leaderboard:{scope}:monthly:2025-01:USD
const monthStr = formatInTimeZone(now, tz, "yyyy-MM");
- return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}`;
+ return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
} else {
// allTime: leaderboard:{scope}:allTime:USD (no date component)
- return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}`;
+ return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
}
}
@@ -89,10 +103,15 @@ async function queryDatabase(
dateRange?: DateRangeParams,
filters?: LeaderboardFilters
): Promise {
+ const userFilters: UserLeaderboardFilters | undefined =
+ scope === "user" && (filters?.userTags?.length || filters?.userGroups?.length)
+ ? { userTags: filters.userTags, userGroups: filters.userGroups }
+ : undefined;
+
// 处理自定义日期范围
if (period === "custom" && dateRange) {
if (scope === "user") {
- return await findCustomRangeLeaderboard(dateRange);
+ return await findCustomRangeLeaderboard(dateRange, userFilters);
}
if (scope === "provider") {
return await findCustomRangeProviderLeaderboard(dateRange, filters?.providerType);
@@ -106,15 +125,15 @@ async function queryDatabase(
if (scope === "user") {
switch (period) {
case "daily":
- return await findDailyLeaderboard();
+ return await findDailyLeaderboard(userFilters);
case "weekly":
- return await findWeeklyLeaderboard();
+ return await findWeeklyLeaderboard(userFilters);
case "monthly":
- return await findMonthlyLeaderboard();
+ return await findMonthlyLeaderboard(userFilters);
case "allTime":
- return await findAllTimeLeaderboard();
+ return await findAllTimeLeaderboard(userFilters);
default:
- return await findDailyLeaderboard();
+ return await findDailyLeaderboard(userFilters);
}
}
if (scope === "provider") {
diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts
index 21aee7162..395d1a7ef 100644
--- a/src/repository/leaderboard.ts
+++ b/src/repository/leaderboard.ts
@@ -19,6 +19,16 @@ export interface LeaderboardEntry {
totalTokens: number;
}
+/**
+ * 用户排行榜筛选参数
+ */
+export interface UserLeaderboardFilters {
+ /** 按用户标签筛选(OR 逻辑:匹配任一标签) */
+ userTags?: string[];
+ /** 按用户分组筛选(OR 逻辑:匹配任一分组) */
+ userGroups?: string[];
+}
+
/**
* 供应商排行榜条目类型
*/
@@ -62,35 +72,43 @@ export interface ModelLeaderboardEntry {
* 查询今日消耗排行榜(不限制数量)
* 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai)
*/
-export async function findDailyLeaderboard(): Promise {
+export async function findDailyLeaderboard(
+ userFilters?: UserLeaderboardFilters
+): Promise {
const timezone = getEnvConfig().TZ;
- return findLeaderboardWithTimezone("daily", timezone);
+ return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters);
}
/**
* 查询本月消耗排行榜(不限制数量)
* 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai)
*/
-export async function findMonthlyLeaderboard(): Promise {
+export async function findMonthlyLeaderboard(
+ userFilters?: UserLeaderboardFilters
+): Promise {
const timezone = getEnvConfig().TZ;
- return findLeaderboardWithTimezone("monthly", timezone);
+ return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters);
}
/**
* 查询本周消耗排行榜(不限制数量)
* 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于配置时区
*/
-export async function findWeeklyLeaderboard(): Promise {
+export async function findWeeklyLeaderboard(
+ userFilters?: UserLeaderboardFilters
+): Promise {
const timezone = getEnvConfig().TZ;
- return findLeaderboardWithTimezone("weekly", timezone);
+ return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters);
}
/**
* 查询全部时间消耗排行榜(不限制数量)
*/
-export async function findAllTimeLeaderboard(): Promise {
+export async function findAllTimeLeaderboard(
+ userFilters?: UserLeaderboardFilters
+): Promise {
const timezone = getEnvConfig().TZ;
- return findLeaderboardWithTimezone("allTime", timezone);
+ return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters);
}
/**
@@ -151,8 +169,40 @@ function buildDateCondition(
async function findLeaderboardWithTimezone(
period: LeaderboardPeriod,
timezone: string,
- dateRange?: DateRangeParams
+ dateRange?: DateRangeParams,
+ userFilters?: UserLeaderboardFilters
): Promise {
+ const whereConditions = [
+ isNull(messageRequest.deletedAt),
+ EXCLUDE_WARMUP_CONDITION,
+ buildDateCondition(period, timezone, dateRange),
+ ];
+
+ const normalizedTags = (userFilters?.userTags ?? []).map((t) => t.trim()).filter(Boolean);
+ let tagFilterCondition: ReturnType | undefined;
+ if (normalizedTags.length > 0) {
+ const tagConditions = normalizedTags.map((tag) => sql`${users.tags} ? ${tag}`);
+ tagFilterCondition = sql`(${sql.join(tagConditions, sql` OR `)})`;
+ }
+
+ const normalizedGroups = (userFilters?.userGroups ?? []).map((g) => g.trim()).filter(Boolean);
+ let groupFilterCondition: ReturnType | undefined;
+ if (normalizedGroups.length > 0) {
+ const groupConditions = normalizedGroups.map(
+ (group) =>
+ sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))`
+ );
+ groupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`;
+ }
+
+ if (tagFilterCondition && groupFilterCondition) {
+ whereConditions.push(sql`(${tagFilterCondition} OR ${groupFilterCondition})`);
+ } else if (tagFilterCondition) {
+ whereConditions.push(tagFilterCondition);
+ } else if (groupFilterCondition) {
+ whereConditions.push(groupFilterCondition);
+ }
+
const rankings = await db
.select({
userId: messageRequest.userId,
@@ -171,13 +221,7 @@ async function findLeaderboardWithTimezone(
})
.from(messageRequest)
.innerJoin(users, and(sql`${messageRequest.userId} = ${users.id}`, isNull(users.deletedAt)))
- .where(
- and(
- isNull(messageRequest.deletedAt),
- EXCLUDE_WARMUP_CONDITION,
- buildDateCondition(period, timezone, dateRange)
- )
- )
+ .where(and(...whereConditions))
.groupBy(messageRequest.userId, users.name)
.orderBy(desc(sql`sum(${messageRequest.costUsd})`));
@@ -194,10 +238,11 @@ async function findLeaderboardWithTimezone(
* 查询自定义日期范围消耗排行榜
*/
export async function findCustomRangeLeaderboard(
- dateRange: DateRangeParams
+ dateRange: DateRangeParams,
+ userFilters?: UserLeaderboardFilters
): Promise {
const timezone = getEnvConfig().TZ;
- return findLeaderboardWithTimezone("custom", timezone, dateRange);
+ return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters);
}
/**
From 58c262b072b7d1f271387e2155b45fa2ca694d18 Mon Sep 17 00:00:00 2001
From: ding113
Date: Wed, 14 Jan 2026 02:46:46 +0800
Subject: [PATCH 03/24] feat(leaderboard): add tag/group suggestions dropdown
for better UX
- Fetch all user tags and groups via getAllUserTags/getAllUserKeyGroups
- Pass suggestions to TagInput for autocomplete dropdown
- Validate input against available suggestions
- Consistent with /dashboard/users filter behavior
---
.../_components/leaderboard-view.tsx | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
index 4cd1f6ef6..c95c6cd4b 100644
--- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
+++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
@@ -3,6 +3,7 @@
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
+import { getAllUserKeyGroups, getAllUserTags } from "@/actions/users";
import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_components/provider-type-filter";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -54,10 +55,27 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
const [providerTypeFilter, setProviderTypeFilter] = useState("all");
const [userTagFilters, setUserTagFilters] = useState([]);
const [userGroupFilters, setUserGroupFilters] = useState([]);
+ const [tagSuggestions, setTagSuggestions] = useState([]);
+ const [groupSuggestions, setGroupSuggestions] = useState([]);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ useEffect(() => {
+ if (!isAdmin) return;
+
+ const fetchSuggestions = async () => {
+ const [tagsResult, groupsResult] = await Promise.all([
+ getAllUserTags(),
+ getAllUserKeyGroups(),
+ ]);
+ if (tagsResult.ok) setTagSuggestions(tagsResult.data);
+ if (groupsResult.ok) setGroupSuggestions(groupsResult.data);
+ };
+
+ fetchSuggestions();
+ }, [isAdmin]);
+
// 与 URL 查询参数保持同步,支持外部携带 scope/period 直达特定榜单
// biome-ignore lint/correctness/useExhaustiveDependencies: period 和 scope 仅用于比较,不应触发 effect 重新执行
useEffect(() => {
@@ -390,6 +408,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
disabled={loading}
maxTags={20}
clearable
+ suggestions={tagSuggestions}
+ allowDuplicates={false}
+ validateTag={(tag) => tagSuggestions.length === 0 || tagSuggestions.includes(tag)}
/>
@@ -400,6 +421,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
disabled={loading}
maxTags={20}
clearable
+ suggestions={groupSuggestions}
+ allowDuplicates={false}
+ validateTag={(tag) => groupSuggestions.length === 0 || groupSuggestions.includes(tag)}
/>
From 6a9e51d768a6a65ad488a61c6751fe230f5aac77 Mon Sep 17 00:00:00 2001
From: ding113
Date: Wed, 14 Jan 2026 11:35:21 +0800
Subject: [PATCH 04/24] docs: update Privnode offer details in README files
---
README.en.md | 3 +--
README.md | 3 +--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.en.md b/README.en.md
index 7b4039cea..25899b3d2 100644
--- a/README.en.md
+++ b/README.en.md
@@ -44,8 +44,7 @@ Cubence offers special discount coupons for users of CCH: when purchasing with t
-💎 Special Offer : Privnode is an affordable AI API aggregation platform providing one-stop relay services for mainstream models like Claude and Codex, serving developers and teams with reliable stability and competitive pricing.
-Use code WITHCCH for 15% off → Visit Now
+Privnode is an affordable AI API aggregation platform providing one-stop relay services for mainstream models like Claude and Codex, serving developers and teams with reliable stability and competitive pricing. → Visit Now
diff --git a/README.md b/README.md
index 370acad13..70a9d9048 100644
--- a/README.md
+++ b/README.md
@@ -44,8 +44,7 @@ Cubence 为 CCH 的使用用户提供了特别的优惠折扣:在购买时使
-💎 特别优惠 :Privnode 是一家平价的 AI API 聚合平台,为 Claude、Codex 等主流模型提供一站式中转服务,以良好的稳定性和较高的性价比,服务于开发者与团队的实际需求。
-使用优惠码 WITHCCH 可获得 15% 折扣 → 立即访问
+Privnode 是一家平价的 AI API 聚合平台,为 Claude、Codex 等主流模型提供一站式中转服务,以良好的稳定性和较高的性价比,服务于开发者与团队的实际需求。 → 立即访问
From c9f3a3a3cad143d967e4e264f9cb8b37ff7ae662 Mon Sep 17 00:00:00 2001
From: ding113
Date: Sun, 18 Jan 2026 17:58:14 +0800
Subject: [PATCH 05/24] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=201M=20?=
=?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=A0=87=E5=A4=B4=E5=85=BC=E5=AE=B9?=
=?UTF-8?q?=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/lib/special-attributes/index.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/special-attributes/index.ts b/src/lib/special-attributes/index.ts
index 7a4b888b6..7ea28f01c 100644
--- a/src/lib/special-attributes/index.ts
+++ b/src/lib/special-attributes/index.ts
@@ -26,7 +26,7 @@ export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
/**
* Context 1M preference types for provider configuration
- * - 'inherit': Follow client request (default)
+ * - 'inherit': Passthrough client headers without modification (default)
* - 'force_enable': Force enable 1M context for supported models
* - 'disabled': Disable 1M context even if client requests it
*/
@@ -86,8 +86,8 @@ export function shouldApplyContext1m(
return isContext1mSupportedModel(model);
}
- // Default (inherit): follow client request for supported models
- return clientRequestedContext1m && isContext1mSupportedModel(model);
+ // Default (inherit): passthrough client headers without modification
+ return false;
}
/**
From ac92b093c9115df9a1b36218f8300ca07fcda4eb Mon Sep 17 00:00:00 2001
From: SaladDay <92240037+SaladDay@users.noreply.github.com>
Date: Sun, 18 Jan 2026 18:24:13 +0800
Subject: [PATCH 06/24] fix: resolve container name conflicts in multi-user
environments (#625)
* fix: resolve container name conflicts in multi-user environments
- Add top-level `name` field with COMPOSE_PROJECT_NAME env var support
- Remove hardcoded container_name from all services
- Users can now set COMPOSE_PROJECT_NAME in .env for complete isolation
Closes #624
* fix: add top-level name field for project isolation
Add `name: ${COMPOSE_PROJECT_NAME:-claude-code-hub}` to enable
complete project isolation via environment variable.
---
docker-compose.yaml | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 5f71ae816..ba4e98241 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,7 +1,8 @@
+name: ${COMPOSE_PROJECT_NAME:-claude-code-hub}
+
services:
postgres:
image: postgres:18
- container_name: claude-code-hub-db
restart: unless-stopped
# 不对外暴露数据库端口,仅允许容器内部网络访问
# 如需调试,可取消注释下行(仅绑定本机):
@@ -31,7 +32,6 @@ services:
redis:
image: redis:7-alpine
- container_name: claude-code-hub-redis
restart: unless-stopped
volumes:
# 持久化 Redis 数据到本地 ./data/redis 目录
@@ -47,7 +47,6 @@ services:
app:
image: ghcr.io/ding113/claude-code-hub:latest
- container_name: claude-code-hub-app
depends_on:
postgres:
condition: service_healthy
From 472cfd475bf2e8e2881320a280b4ec1234f8fc9c Mon Sep 17 00:00:00 2001
From: miraserver <20286838+miraserver@users.noreply.github.com>
Date: Mon, 19 Jan 2026 07:52:26 +0300
Subject: [PATCH 07/24] feat(dashboard): improve user management, statistics
reset, and i18n (#610)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(dashboard/logs): add reset options to filters and use short time format
- Add "All keys" SelectItem to API Key filter dropdown
- Add "All status codes" SelectItem to Status Code filter dropdown
- Use __all__ value instead of empty string (Radix Select requirement)
- Add formatDateDistanceShort() for compact time display (2h ago, 3d ago)
- Update RelativeTime component to use short format
Co-Authored-By: Claude Opus 4.5
* fix(providers): add Auto Sort button to dashboard and fix i18n
- Add AutoSortPriorityDialog to dashboard/providers page
- EN: "Auto Sort Priority" -> "Auto Sort"
- RU: "Авто сортировка приоритета" -> "Автосорт"
- RU: "Добавить провайдера" -> "Добавить поставщика"
Co-Authored-By: Claude Opus 4.5
* fix(i18n): improve Russian localization and fix login errors
Russian localization improvements:
- Menu: "Управление поставщиками" -> "Поставщики"
- Menu: "Доступность" -> "Мониторинг"
- Filters: "Последние 7/30 дней" -> "7д/30д"
- Dashboard: "Статистика использования" -> "Статистика"
- Dashboard: "Показать статистику..." -> "Только ваши ключи"
- Quota: add missing translations (manageNotice, withQuotas, etc.)
Login error localization:
- Fix issue where login errors displayed in Chinese ("无效或已过期") regardless of locale
- Add locale detection from NEXT_LOCALE cookie and Accept-Language header
- Add 3 new error keys: apiKeyRequired, apiKeyInvalidOrExpired, serverError
- Support all 5 languages: EN, JA, RU, ZH-CN, ZH-TW
- Remove product name from login privacyNote for all locales
Files changed:
- messages/*/auth.json: new error keys, update privacyNote
- messages/ru/dashboard.json, messages/ru/quota.json: Russian improvements
- src/app/api/auth/login/route.ts: add getLocaleFromRequest()
Co-Authored-By: Claude Opus 4.5
Co-Authored-By: Claude Sonnet 4.5
* feat(dashboard/users): improve user management with key quotas and tokens
- Add access/model restrictions support (allowedClients/allowedModels)
- Add tokens column and refresh button to users table
- Add todayTokens calculation in repository layer (sum all token types)
- Add visual status indicators with color-coded icons (active/disabled/expiring/expired)
- Allow users to view their own key quota (was admin-only)
- Fix React Query cache invalidation on status toggle
- Fix filter logic: change tag/keyGroup from OR to AND
- Refactor time display: move formatDateDistanceShort to component with i18n
- Add fixed header/footer to key dialogs for better UX
Co-Authored-By: Claude Sonnet 4.5
* feat(dashboard/users): add reset statistics with optimized Redis pipeline
- Implement reset all statistics functionality for admins
- Optimize Redis operations: replace sequential redis.keys() with parallel SCAN
- Add scanPattern() helper for production-safe key scanning
- Comprehensive error handling and performance metrics logging
- 50-100x performance improvement with no Redis blocking
Co-Authored-By: Claude Sonnet 4.5
* fix(lint): apply biome formatting and fix React hooks dependencies
- Fix useEffect dependencies in RelativeTime component (wrap formatShortDistance in useCallback)
- Remove unused effectiveGroupText variable in key-row-item.tsx
- Apply consistent LF line endings across modified files
Co-Authored-By: Claude Opus 4.5
* fix: address code review feedback from PR #610
- Remove duplicate max-h class in edit-key-dialog.tsx (keep max-h-[90dvh] only)
- Add try-catch fallback for getTranslations in login route catch block
Co-Authored-By: Claude Opus 4.5
* fix: translate Russian comments to English for consistency
Addresses Gemini Code Assist review feedback.
Co-Authored-By: Claude Opus 4.5
* test(users): add unit tests for resetUserAllStatistics function
Cover all requirement scenarios:
- Permission check (admin-only)
- User not found handling
- Success path with DB + Redis cleanup
- Redis not ready graceful handling
- Redis partial failure warning
- scanPattern failure warning
- pipeline.exec failure error logging
- Unexpected error handling
- Empty keys list handling
10 test cases with full mock coverage.
Co-Authored-By: Claude Opus 4.5
---------
Co-authored-by: John Doe
Co-authored-by: Claude Opus 4.5
---
messages/en/auth.json | 7 +-
messages/en/common.json | 12 +-
messages/en/dashboard.json | 80 +++++-
messages/en/quota.json | 12 +-
messages/en/settings/providers/autoSort.json | 2 +-
messages/ja/auth.json | 7 +-
messages/ja/common.json | 12 +-
messages/ja/dashboard.json | 52 +++-
messages/ja/quota.json | 18 +-
messages/ru/auth.json | 9 +-
messages/ru/common.json | 12 +-
messages/ru/dashboard.json | 123 +++++++--
messages/ru/quota.json | 45 +++-
messages/ru/settings/providers/autoSort.json | 2 +-
.../ru/settings/providers/form/title.json | 4 +-
messages/ru/settings/providers/strings.json | 4 +-
messages/zh-CN/auth.json | 7 +-
messages/zh-CN/common.json | 12 +-
messages/zh-CN/dashboard.json | 29 ++-
messages/zh-TW/auth.json | 7 +-
messages/zh-TW/common.json | 12 +-
messages/zh-TW/dashboard.json | 52 +++-
messages/zh-TW/quota.json | 18 +-
src/actions/key-quota.ts | 10 +-
src/actions/users.ts | 136 +++++++++-
.../_components/user/add-key-dialog.tsx | 16 +-
.../user/batch-edit/batch-edit-dialog.tsx | 2 +
.../_components/user/edit-key-dialog.tsx | 2 +-
.../_components/user/edit-user-dialog.tsx | 104 +++++++-
.../_components/user/forms/add-key-form.tsx | 12 +-
.../_components/user/forms/edit-key-form.tsx | 4 +
.../dashboard/_components/user/key-list.tsx | 2 +-
.../_components/user/key-row-item.tsx | 55 ++--
.../_components/user/user-key-table-row.tsx | 98 +++++--
.../user/user-management-table.tsx | 19 +-
.../logs/_components/usage-logs-filters.tsx | 15 +-
.../logs/_components/usage-logs-table.tsx | 2 +-
.../_components/virtualized-logs-table.tsx | 2 +-
src/app/[locale]/dashboard/providers/page.tsx | 2 +
.../dashboard/users/users-page-client.tsx | 59 ++++-
src/app/api/auth/login/route.ts | 40 ++-
src/components/form/form-layout.tsx | 6 +-
src/components/section.tsx | 8 +-
src/components/ui/relative-time.tsx | 44 +++-
src/lib/redis/index.ts | 1 +
src/lib/redis/scan-helper.ts | 32 +++
src/repository/key.ts | 14 +-
src/repository/user.ts | 2 +-
src/types/user.ts | 3 +
.../users-reset-all-statistics.test.ts | 246 ++++++++++++++++++
tests/unit/lib/redis/scan-helper.test.ts | 39 +++
51 files changed, 1334 insertions(+), 179 deletions(-)
create mode 100644 src/lib/redis/scan-helper.ts
create mode 100644 tests/unit/actions/users-reset-all-statistics.test.ts
create mode 100644 tests/unit/lib/redis/scan-helper.test.ts
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 e752d461b..499e57682 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -785,6 +785,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": {
@@ -1148,18 +1157,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": {
@@ -1250,7 +1262,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",
@@ -1351,6 +1374,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",
@@ -1360,6 +1418,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)"
@@ -1372,8 +1436,7 @@
"500": "$500"
},
"alreadySet": "Configured",
- "confirmAdd": "Add",
- "cancel": "Cancel"
+ "confirmAdd": "Add"
},
"quickExpire": {
"oneWeek": "In 1 week",
@@ -1596,6 +1659,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 030f0868c..e86cac34e 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -721,7 +721,8 @@
"limit5hUsd": {
"label": "5時間消費上限 (USD)",
"placeholder": "空白の場合は無制限",
- "description": "5時間以内の最大消費金額"
+ "description": "5時間以内の最大消費金額",
+ "descriptionWithUserLimit": "5時間以内の最大消費金額 (ユーザー上限: {limit})"
},
"limitDailyUsd": {
"label": "1日の消費上限 (USD)",
@@ -747,17 +748,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": "プロバイダーグループ",
@@ -766,6 +776,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": {
@@ -1119,18 +1138,21 @@
"name": "キー名",
"key": "キー",
"group": "グループ",
- "todayUsage": "本日の使用量",
+ "todayUsage": "本日のリクエスト",
"todayCost": "本日の消費",
+ "todayTokens": "本日のトークン",
"lastUsed": "最終使用",
"actions": "アクション",
"quotaButton": "クォータ使用状況を表示",
"fields": {
- "callsLabel": "呼び出し",
+ "callsLabel": "リクエスト",
+ "tokensLabel": "トークン",
"costLabel": "消費"
}
},
"expand": "展開",
"collapse": "折りたたむ",
+ "refresh": "更新",
"noKeys": "キーなし",
"defaultGroup": "default",
"userStatus": {
@@ -1182,6 +1204,10 @@
"currentExpiry": "現在の有効期限",
"neverExpires": "無期限",
"expired": "期限切れ",
+ "quickExtensionLabel": "クイック延長",
+ "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)",
+ "customDateLabel": "有効期限を設定",
+ "customDateHint": "有効期限を直接指定",
"quickOptions": {
"7days": "7 日",
"30days": "30 日",
@@ -1190,6 +1216,7 @@
},
"customDate": "カスタム日付",
"enableOnRenew": "同時にユーザーを有効化",
+ "enableKeyOnRenew": "同時にキーを有効化",
"cancel": "キャンセル",
"confirm": "更新を確認",
"confirming": "更新中...",
@@ -1212,7 +1239,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 e3d6e9f5b..3abb884e3 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -536,11 +536,11 @@
"dashboard": "Панель",
"usageLogs": "Журналы",
"leaderboard": "Лидеры",
- "availability": "Доступность",
+ "availability": "Мониторинг",
"myQuota": "Моя квота",
"quotasManagement": "Квоты",
"userManagement": "Пользователи",
- "providers": "Управление поставщиками",
+ "providers": "Поставщики",
"documentation": "Доки",
"systemSettings": "Настройки",
"feedback": "Обратная связь",
@@ -548,7 +548,7 @@
"logout": "Выход"
},
"statistics": {
- "title": "Статистика использования",
+ "title": "Статистика",
"cost": "Сумма расходов",
"calls": "Количество вызовов API",
"totalCost": "Общая сумма расходов",
@@ -556,18 +556,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": "Выбрать все",
@@ -723,7 +723,8 @@
"limit5hUsd": {
"label": "Лимит расходов за 5 часов (USD)",
"placeholder": "Оставьте пустым для неограниченного",
- "description": "Максимальный расход в течение 5 часов"
+ "description": "Максимальный расход в течение 5 часов",
+ "descriptionWithUserLimit": "Максимальный расход за 5 часов (Лимит пользователя: {limit})"
},
"limitDailyUsd": {
"label": "Дневной лимит расходов (USD)",
@@ -749,17 +750,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": "Группа провайдеров",
@@ -768,6 +778,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": {
@@ -922,7 +941,7 @@
"last1h": "Последний час",
"last6h": "Последние 6 часов",
"last24h": "Последние 24 часа",
- "last7d": "Последние 7 дней",
+ "last7d": "7д",
"custom": "Настраиваемый"
},
"filters": {
@@ -1126,18 +1145,21 @@
"name": "Название ключа",
"key": "Ключ",
"group": "Группа",
- "todayUsage": "Использование сегодня",
+ "todayUsage": "Запросы сегодня",
"todayCost": "Расход сегодня",
+ "todayTokens": "Токены сегодня",
"lastUsed": "Последнее использование",
"actions": "Действия",
"quotaButton": "Просмотр использования квоты",
"fields": {
- "callsLabel": "Вызовы",
+ "callsLabel": "Запросы",
+ "tokensLabel": "Токены",
"costLabel": "Расход"
}
},
"expand": "Развернуть",
"collapse": "Свернуть",
+ "refresh": "Обновить",
"noKeys": "Нет ключей",
"defaultGroup": "default",
"userStatus": {
@@ -1189,6 +1211,10 @@
"currentExpiry": "Текущий срок",
"neverExpires": "Бессрочно",
"expired": "Истёк",
+ "quickExtensionLabel": "Быстрое продление",
+ "quickExtensionHint": "Продлить от текущего срока (или от сейчас, если истёк)",
+ "customDateLabel": "Указать дату",
+ "customDateHint": "Напрямую указать дату истечения",
"quickOptions": {
"7days": "7 дней",
"30days": "30 дней",
@@ -1197,6 +1223,7 @@
},
"customDate": "Произвольная дата",
"enableOnRenew": "Также включить пользователя",
+ "enableKeyOnRenew": "Также включить ключ",
"cancel": "Отмена",
"confirm": "Подтвердить продление",
"confirming": "Продление...",
@@ -1223,7 +1250,18 @@
"userEnabled": "Пользователь активирован",
"deleteFailed": "Не удалось удалить пользователя",
"userDeleted": "Пользователь удален",
- "saving": "Сохранение..."
+ "saving": "Сохранение...",
+ "resetData": {
+ "title": "Сброс статистики",
+ "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.",
+ "error": "Не удалось сбросить данные",
+ "button": "Сбросить статистику",
+ "confirmTitle": "Сбросить всю статистику?",
+ "confirmDescription": "Это навсегда удалит все логи запросов и статистику использования для этого пользователя. Это действие нельзя отменить.",
+ "confirm": "Да, сбросить все",
+ "loading": "Сброс...",
+ "success": "Вся статистика сброшена"
+ }
},
"batchEdit": {
"enterMode": "Массовое редактирование",
@@ -1324,6 +1362,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 часов",
@@ -1333,6 +1406,12 @@
"limitTotal": "Общий лимит",
"limitSessions": "Одновременные сессии"
},
+ "errors": {
+ "missingType": "Пожалуйста, выберите тип лимита",
+ "invalidValue": "Пожалуйста, введите корректное значение",
+ "invalidTime": "Пожалуйста, введите корректное время (ЧЧ:мм)"
+ },
+ "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение",
"dailyMode": {
"fixed": "Сброс по фиксированному времени",
"rolling": "Скользящее окно (24ч)"
@@ -1345,8 +1424,7 @@
"500": "$500"
},
"alreadySet": "Уже настроено",
- "confirmAdd": "Добавить",
- "cancel": "Отмена"
+ "confirmAdd": "Добавить"
},
"quickExpire": {
"oneWeek": "Через неделю",
@@ -1535,7 +1613,9 @@
},
"balanceQueryPage": {
"label": "Независимая страница использования",
- "description": "При включении этот ключ может использовать независимую страницу личного использования"
+ "description": "При включении этот ключ может использовать независимую страницу личного использования",
+ "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.",
+ "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI."
},
"providerGroup": {
"label": "Группа провайдеров",
@@ -1568,6 +1648,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 0df593e25..282ff6db9 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -786,6 +786,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": {
@@ -1149,18 +1158,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": {
@@ -1251,7 +1263,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 cff75d534..6bc25609b 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -721,7 +721,8 @@
"limit5hUsd": {
"label": "5小時消費上限(USD)",
"placeholder": "留空表示無限制",
- "description": "5小時內最大消費金額"
+ "description": "5小時內最大消費金額",
+ "descriptionWithUserLimit": "5小時內最大消費金額(使用者上限:{limit})"
},
"limitDailyUsd": {
"label": "每日消費上限(USD)",
@@ -747,17 +748,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": "供應商分組",
@@ -766,6 +776,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": {
@@ -1124,18 +1143,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": {
@@ -1187,6 +1209,10 @@
"currentExpiry": "目前到期時間",
"neverExpires": "永不過期",
"expired": "已過期",
+ "quickExtensionLabel": "快速延期",
+ "quickExtensionHint": "從目前到期日延長(若已過期則從現在開始)",
+ "customDateLabel": "設定到期日",
+ "customDateHint": "直接指定到期日期",
"quickOptions": {
"7days": "7天",
"30days": "30天",
@@ -1195,6 +1221,7 @@
},
"customDate": "自訂日期",
"enableOnRenew": "同時啟用使用者",
+ "enableKeyOnRenew": "同時啟用金鑰",
"cancel": "取消續期",
"confirm": "確認續期",
"confirming": "續期中...",
@@ -1221,7 +1248,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")}
-
+
-
-
- {tCommon("close")}
-
-
+
+
+
+ {tCommon("close")}
+
>
) : (
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 (