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% offVisit 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")} -
+
@@ -106,11 +106,11 @@ export function AddKeyDialog({

{t("generatedKey.hint")}

-
- -
+
+
+
) : ( diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index 8bc5a3e5d..be23ff43f 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -376,6 +376,8 @@ function BatchEditDialogInner({ if (anySuccess) { await queryClient.invalidateQueries({ queryKey: ["users"] }); + await queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + await queryClient.invalidateQueries({ queryKey: ["userTags"] }); } // Only close dialog and clear selection when fully successful diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index 884745ab8..ea543338f 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -52,7 +52,7 @@ export function EditKeyDialog({ return ( - + {t("title")} {t("description")} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index c3e2a4686..5c963c72c 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,14 +1,25 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, UserCog } from "lucide-react"; +import { Loader2, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { editUser, removeUser, toggleUserEnabled } from "@/actions/users"; -import { Button } from "@/components/ui/button"; +import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -18,6 +29,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { cn } from "@/lib/utils"; import { UpdateUserSchema } from "@/lib/validation/schemas"; import type { UserDisplay } from "@/types/user"; import { DangerZone } from "./forms/danger-zone"; @@ -71,6 +83,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); const [isPending, startTransition] = useTransition(); + const [isResettingAll, setIsResettingAll] = useState(false); + const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -110,6 +124,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] submit failed", error); @@ -161,6 +177,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userDisabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] disable user failed", error); @@ -178,6 +196,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr toast.success(t("editDialog.userEnabled")); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); } catch (error) { console.error("[EditUserDialog] enable user failed", error); @@ -194,9 +214,32 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr onSuccess?.(); onOpenChange(false); queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); router.refresh(); }; + const handleResetAllStatistics = async () => { + setIsResettingAll(true); + try { + const res = await resetUserAllStatistics(user.id); + if (!res.ok) { + toast.error(res.error || t("editDialog.resetData.error")); + return; + } + toast.success(t("editDialog.resetData.success")); + setResetAllDialogOpen(false); + + // Full page reload to ensure all cached data is refreshed + window.location.reload(); + } catch (error) { + console.error("[EditUserDialog] reset all statistics failed", error); + toast.error(t("editDialog.resetData.error")); + } finally { + setIsResettingAll(false); + } + }; + return (
@@ -243,6 +286,59 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr modelSuggestions={modelSuggestions} /> + {/* Reset Data Section - Admin Only */} +
+
+
+

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

+

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

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

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

+

{t("cacheTtl.description")}

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

- {effectiveGroupText} -

+
    + {effectiveGroups.map((group) => ( +
  • {group}
  • + ))} +
@@ -434,23 +444,28 @@ export function KeyRowItem({ className="text-right tabular-nums flex items-center justify-end gap-1" title={translations.fields.todayUsage} > - {translations.fields.callsLabel}: + {Number(keyData.todayCallCount || 0).toLocaleString()} - {/* 今日消耗(成本) */} + {/* 今日Token数 */}
- {translations.fields.costLabel}: - {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)} + + {formatTokenAmount(keyData.todayTokens || 0)} +
+ + {/* 今日消耗(成本) */} +
+ {formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}
{/* 最后使用 */}
{keyData.lastUsedAt ? ( - + ) : ( - )} diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 5be783d78..68a7ffb1f 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -1,7 +1,16 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, Plus, SquarePen } from "lucide-react"; +import { + CheckCircle2, + ChevronDown, + ChevronRight, + CircleOff, + Clock, + Plus, + SquarePen, + XCircle, +} from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -209,6 +218,8 @@ export function UserKeyTableRow({ return; } toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled")); + // Инвалидировать кэш React Query для всех фильтров + queryClient.invalidateQueries({ queryKey: ["users"] }); // 刷新服务端数据 router.refresh(); } catch (error) { @@ -267,30 +278,68 @@ export function UserKeyTableRow({ {isExpanded ? translations.collapse : translations.expand} + + + + {expiryStatus.label === "active" && ( + + )} + {expiryStatus.label === "disabled" && ( + + )} + {expiryStatus.label === "expiringSoon" && ( + + )} + {expiryStatus.label === "expired" && ( + + )} + + + {tUserStatus(expiryStatus.label)} + {user.name} - - {tUserStatus(expiryStatus.label)} - - {visibleGroups.map((group) => { - const bgColor = getGroupColor(group); - return ( - - {group} - - ); - })} - {remainingGroupsCount > 0 && ( - - +{remainingGroupsCount} - - )} + {userGroups.length > 0 ? ( + + +
+ {visibleGroups.map((group) => { + if (group.toLowerCase() === "default") { + return ( + + {group} + + ); + } + const bgColor = getGroupColor(group); + return ( + + {group} + + ); + })} + {remainingGroupsCount > 0 && ( + + +{remainingGroupsCount} + + )} +
+
+ +
    + {userGroups.map((group) => ( +
  • {group}
  • + ))} +
+
+
+ ) : null} {user.tags && user.tags.length > 0 && ( [{user.tags.join(", ")}] @@ -453,6 +502,7 @@ export function UserKeyTableRow({ providerGroup: key.providerGroup, todayUsage: key.todayUsage, todayCallCount: key.todayCallCount, + todayTokens: key.todayTokens, lastUsedAt: key.lastUsedAt, expiresAt: key.expiresAt, status: key.status, diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index 948f92349..f0e2b429e 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Users } from "lucide-react"; +import { Loader2, RefreshCw, Users } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -91,6 +91,8 @@ export interface UserManagementTableProps { failed: string; }; }; + onRefresh?: () => void; + isRefreshing?: boolean; } const USER_ROW_HEIGHT = 52; @@ -124,6 +126,8 @@ export function UserManagementTable({ onSelectKey, onOpenBatchEdit, translations, + onRefresh, + isRefreshing, }: UserManagementTableProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -422,6 +426,19 @@ export function UserManagementTable({ /> ) : null}
+ + {onRefresh ? ( + + ) : null}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 8a107d1cb..3d35c1e0d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -419,11 +419,11 @@ export function UsageLogsFilters({
setLocalFilters({ ...localFilters, - statusCode: value && value !== "!200" ? parseInt(value, 10) : undefined, + statusCode: + value && value !== "!200" && value !== "__all__" + ? parseInt(value, 10) + : undefined, excludeStatusCode200: value === "!200", }) } @@ -617,6 +623,7 @@ export function UsageLogsFilters({ + {t("logs.filters.allStatusCodes")} {t("logs.statusCodes.not200")} {t("logs.statusCodes.200")} {t("logs.statusCodes.400")} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index b620cfa3d..c02b907dc 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -124,7 +124,7 @@ export function UsageLogsTable({ >
- +
{log.userName} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 52f07e023..e38646a3d 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -270,7 +270,7 @@ export function VirtualizedLogsTable({ > {/* Time */}
- +
{/* User */} diff --git a/src/app/[locale]/dashboard/providers/page.tsx b/src/app/[locale]/dashboard/providers/page.tsx index dc5d2b2d8..250895d9f 100644 --- a/src/app/[locale]/dashboard/providers/page.tsx +++ b/src/app/[locale]/dashboard/providers/page.tsx @@ -1,5 +1,6 @@ import { BarChart3 } from "lucide-react"; import { getTranslations } from "next-intl/server"; +import { AutoSortPriorityDialog } from "@/app/[locale]/settings/providers/_components/auto-sort-priority-dialog"; import { ProviderManagerLoader } from "@/app/[locale]/settings/providers/_components/provider-manager-loader"; import { SchedulingRulesDialog } from "@/app/[locale]/settings/providers/_components/scheduling-rules-dialog"; import { Section } from "@/components/section"; @@ -50,6 +51,7 @@ export default async function DashboardProvidersPage({ {t("providers.section.leaderboard")} + } diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index d7997a207..faa78d1f9 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -7,7 +7,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { Loader2, Plus, Search } from "lucide-react"; +import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users"; @@ -67,6 +67,8 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { const tUserMgmt = useTranslations("dashboard.userManagement"); const tKeyList = useTranslations("dashboard.keyList"); const tCommon = useTranslations("common"); + const tProviderGroup = useTranslations("myUsage.providerGroup"); + const tRestrictions = useTranslations("myUsage.accessRestrictions"); const queryClient = useQueryClient(); const isAdmin = currentUser.role === "admin"; const [searchTerm, setSearchTerm] = useState(""); @@ -138,6 +140,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { isFetching, isError, error, + refetch, } = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam }) => { @@ -434,9 +437,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { group: tUserMgmt("table.keyRow.group"), todayUsage: tUserMgmt("table.keyRow.todayUsage"), todayCost: tUserMgmt("table.keyRow.todayCost"), + todayTokens: tUserMgmt("table.keyRow.todayTokens"), lastUsed: tUserMgmt("table.keyRow.lastUsed"), actions: tUserMgmt("table.keyRow.actions"), callsLabel: tUserMgmt("table.keyRow.fields.callsLabel"), + tokensLabel: tUserMgmt("table.keyRow.fields.tokensLabel"), costLabel: tUserMgmt("table.keyRow.fields.costLabel"), }, actions: { @@ -504,6 +509,55 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { )}
+ {/* Provider Group & Access Restrictions block (non-admin users only) */} + {!isAdmin && selfUser && ( +
+ {/* Provider Groups */} +
+
+ + {tProviderGroup("title")} +
+
+
+ + {tProviderGroup("userGroup")}: + + + {selfUser.providerGroup || tProviderGroup("allProviders")} + +
+
+
+ + {/* Access Restrictions */} +
+
+ + {tRestrictions("title")} +
+
+
+ {tRestrictions("models")}: + + {selfUser.allowedModels?.length + ? selfUser.allowedModels.join(", ") + : tRestrictions("noRestrictions")} + +
+
+ {tRestrictions("clients")}: + + {selfUser.allowedClients?.length + ? selfUser.allowedClients.join(", ") + : tRestrictions("noRestrictions")} + +
+
+
+
+ )} + {/* Toolbar with search and filters */}
{/* Search input */} @@ -628,7 +682,6 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
) : (
-
{isRefreshing ? : null}
refetch()} + isRefreshing={isRefreshing} />
)} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3bee8b20d..cc09adb16 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,21 +1,50 @@ import { type NextRequest, NextResponse } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { defaultLocale, type Locale, locales } from "@/i18n/config"; import { getLoginRedirectTarget, setAuthCookie, validateKey } from "@/lib/auth"; import { logger } from "@/lib/logger"; // 需要数据库连接 export const runtime = "nodejs"; +/** + * Get locale from request (cookie or Accept-Language header) + */ +function getLocaleFromRequest(request: NextRequest): Locale { + // 1. Check NEXT_LOCALE cookie + const localeCookie = request.cookies.get("NEXT_LOCALE")?.value; + if (localeCookie && locales.includes(localeCookie as Locale)) { + return localeCookie as Locale; + } + + // 2. Check Accept-Language header + const acceptLanguage = request.headers.get("accept-language"); + if (acceptLanguage) { + for (const locale of locales) { + if (acceptLanguage.toLowerCase().includes(locale.toLowerCase())) { + return locale; + } + } + } + + // 3. Fall back to default + return defaultLocale; +} + export async function POST(request: NextRequest) { + const locale = getLocaleFromRequest(request); + try { + const t = await getTranslations({ locale, namespace: "auth.errors" }); const { key } = await request.json(); if (!key) { - return NextResponse.json({ error: "请输入 API Key" }, { status: 400 }); + return NextResponse.json({ error: t("apiKeyRequired") }, { status: 400 }); } const session = await validateKey(key, { allowReadOnlyAccess: true }); if (!session) { - return NextResponse.json({ error: "API Key 无效或已过期" }, { status: 401 }); + return NextResponse.json({ error: t("apiKeyInvalidOrExpired") }, { status: 401 }); } // 设置认证 cookie @@ -35,6 +64,11 @@ export async function POST(request: NextRequest) { }); } catch (error) { logger.error("Login error:", error); - return NextResponse.json({ error: "登录失败,请稍后重试" }, { status: 500 }); + try { + const t = await getTranslations({ locale, namespace: "auth.errors" }); + return NextResponse.json({ error: t("serverError") }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } } } diff --git a/src/components/form/form-layout.tsx b/src/components/form/form-layout.tsx index 8c28325fd..6205df5e8 100644 --- a/src/components/form/form-layout.tsx +++ b/src/components/form/form-layout.tsx @@ -51,12 +51,12 @@ export function DialogFormLayout({ const t = useTranslations("forms"); return ( - + {config.title} {config.description && {config.description}} -
+
{children} @@ -68,7 +68,7 @@ export function DialogFormLayout({
- +
diff --git a/src/app/[locale]/my-usage/_components/expiration-info.tsx b/src/app/[locale]/my-usage/_components/expiration-info.tsx index 5f75f7837..51757bd3a 100644 --- a/src/app/[locale]/my-usage/_components/expiration-info.tsx +++ b/src/app/[locale]/my-usage/_components/expiration-info.tsx @@ -1,7 +1,7 @@ "use client"; +import { Clock } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { useCountdown } from "@/hooks/useCountdown"; import { cn } from "@/lib/utils"; import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format"; @@ -56,6 +56,14 @@ export function ExpirationInfo({ expired: "text-destructive", }; + const countdownStyles: Record = { + none: "text-muted-foreground", + normal: "text-emerald-600 dark:text-emerald-400", + warning: "text-amber-600 dark:text-amber-400", + danger: "text-red-600 dark:text-red-400", + expired: "text-destructive", + }; + const renderItem = ( label: string, value: Date | null, @@ -79,9 +87,11 @@ export function ExpirationInfo({
{showCountdown ? ( -
- {t("expiresIn", { time: countdown.shortFormatted })} - +
+ + + {countdown.shortFormatted} +
) : null}
diff --git a/src/app/[locale]/my-usage/_components/my-usage-header.tsx b/src/app/[locale]/my-usage/_components/my-usage-header.tsx index b3258e2c1..2acee5325 100644 --- a/src/app/[locale]/my-usage/_components/my-usage-header.tsx +++ b/src/app/[locale]/my-usage/_components/my-usage-header.tsx @@ -2,60 +2,19 @@ import { LogOut } from "lucide-react"; import { useTranslations } from "next-intl"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { Button } from "@/components/ui/button"; -import { useCountdown } from "@/hooks/useCountdown"; import { useRouter } from "@/i18n/routing"; -import { cn } from "@/lib/utils"; interface MyUsageHeaderProps { onLogout?: () => Promise | void; keyName?: string; userName?: string; - keyExpiresAt?: Date | null; - userExpiresAt?: Date | null; } -export function MyUsageHeader({ - onLogout, - keyName, - userName, - keyExpiresAt, - userExpiresAt, -}: MyUsageHeaderProps) { +export function MyUsageHeader({ onLogout, keyName, userName }: MyUsageHeaderProps) { const t = useTranslations("myUsage.header"); - const tExpiration = useTranslations("myUsage.expiration"); const router = useRouter(); - const keyCountdown = useCountdown(keyExpiresAt ?? null, Boolean(keyExpiresAt)); - const userCountdown = useCountdown(userExpiresAt ?? null, Boolean(userExpiresAt)); - - const renderCountdownChip = ( - label: string, - expiresAt: Date | null | undefined, - countdown: ReturnType - ) => { - if (!expiresAt || countdown.isExpired || countdown.totalSeconds > 7 * 24 * 60 * 60) return null; - - const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning"; - const toneClass = - tone === "danger" - ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200" - : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100"; - - return ( - - {label} - - - ); - }; - const handleLogout = async () => { if (onLogout) { await onLogout(); @@ -74,8 +33,6 @@ export function MyUsageHeader({

{userName ? t("welcome", { name: userName }) : t("title")}

- {renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)} - {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
diff --git a/src/app/[locale]/my-usage/_components/quota-cards.tsx b/src/app/[locale]/my-usage/_components/quota-cards.tsx index d1d3b6f4d..68a8a3a30 100644 --- a/src/app/[locale]/my-usage/_components/quota-cards.tsx +++ b/src/app/[locale]/my-usage/_components/quota-cards.tsx @@ -3,11 +3,9 @@ import { useTranslations } from "next-intl"; import { useMemo } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; -import { useCountdown } from "@/hooks/useCountdown"; import type { CurrencyCode } from "@/lib/utils"; import { cn } from "@/lib/utils"; import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers"; @@ -16,67 +14,12 @@ interface QuotaCardsProps { quota: MyUsageQuota | null; loading?: boolean; currencyCode?: CurrencyCode; - keyExpiresAt?: Date | null; - userExpiresAt?: Date | null; } -export function QuotaCards({ - quota, - loading = false, - currencyCode = "USD", - keyExpiresAt, - userExpiresAt, -}: QuotaCardsProps) { +export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: QuotaCardsProps) { const t = useTranslations("myUsage.quota"); - const tExpiration = useTranslations("myUsage.expiration"); const tCommon = useTranslations("common"); - const resolvedKeyExpires = keyExpiresAt ?? quota?.expiresAt ?? null; - const resolvedUserExpires = userExpiresAt ?? quota?.userExpiresAt ?? null; - - const shouldEnableCountdown = !(loading && !quota); - - const keyCountdown = useCountdown( - resolvedKeyExpires, - shouldEnableCountdown && Boolean(resolvedKeyExpires) - ); - const userCountdown = useCountdown( - resolvedUserExpires, - shouldEnableCountdown && Boolean(resolvedUserExpires) - ); - - const isExpiring = (countdown: ReturnType) => - countdown.totalSeconds > 0 && countdown.totalSeconds <= 7 * 24 * 60 * 60; - - const showKeyBadge = resolvedKeyExpires && !keyCountdown.isExpired && isExpiring(keyCountdown); - const showUserBadge = - resolvedUserExpires && !userCountdown.isExpired && isExpiring(userCountdown); - - const renderExpireBadge = ( - label: string, - resetAt: Date | null, - countdown: ReturnType - ) => { - if (!resetAt) return null; - const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning"; - const toneClass = - tone === "danger" - ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200" - : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100"; - - return ( - - {label} - - - ); - }; - const items = useMemo(() => { if (!quota) return []; return [ @@ -137,20 +80,6 @@ export function QuotaCards({ return (
- {showKeyBadge || showUserBadge ? ( -
- - {tExpiration("expiringWarning")} - - {showKeyBadge - ? renderExpireBadge(tExpiration("keyExpires"), resolvedKeyExpires, keyCountdown) - : null} - {showUserBadge - ? renderExpireBadge(tExpiration("userExpires"), resolvedUserExpires, userCountdown) - : null} -
- ) : null} -
{items.map((item) => { const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit); diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index 2a848cfdf..a17af0415 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -1,11 +1,24 @@ "use client"; -import { BarChart3, RefreshCw } from "lucide-react"; +import { format } from "date-fns"; +import { + Activity, + ArrowDownRight, + ArrowUpRight, + BarChart3, + Coins, + Database, + Hash, + Percent, + RefreshCw, + Target, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { formatTokenAmount } from "@/lib/utils"; @@ -26,7 +39,7 @@ export function StatisticsSummaryCard({ const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>(() => { - const today = new Date().toISOString().split("T")[0]; + const today = format(new Date(), "yyyy-MM-dd"); return { startDate: today, endDate: today }; }); const intervalRef = useRef(null); @@ -219,7 +232,10 @@ export function StatisticsSummaryCard({ cost={item.cost} inputTokens={item.inputTokens} outputTokens={item.outputTokens} + cacheCreationTokens={item.cacheCreationTokens} + cacheReadTokens={item.cacheReadTokens} currencyCode={currencyCode} + totalCost={stats.totalCost} /> ))}
@@ -243,7 +259,10 @@ export function StatisticsSummaryCard({ cost={item.cost} inputTokens={item.inputTokens} outputTokens={item.outputTokens} + cacheCreationTokens={item.cacheCreationTokens} + cacheReadTokens={item.cacheReadTokens} currencyCode={currencyCode} + totalCost={stats.totalCost} /> ))}
@@ -268,7 +287,10 @@ interface ModelBreakdownRowProps { cost: number; inputTokens: number; outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; currencyCode: CurrencyCode; + totalCost: number; } function ModelBreakdownRow({ @@ -277,21 +299,196 @@ function ModelBreakdownRow({ cost, inputTokens, outputTokens, + cacheCreationTokens, + cacheReadTokens, currencyCode, + totalCost, }: ModelBreakdownRowProps) { + const [open, setOpen] = useState(false); const t = useTranslations("myUsage.stats"); + const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = + totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; + const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; + + const cacheHitRateNum = Number.parseFloat(cacheHitRate); + const cacheHitColor = + cacheHitRateNum >= 85 + ? "text-green-600 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + return ( -
-
- {model || t("unknownModel")} - - {requests.toLocaleString()} req · {formatTokenAmount(inputTokens + outputTokens)} tok - -
-
- {formatCurrency(cost, currencyCode)} + <> +
setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen(true); + } + }} + > +
+ {model || t("unknownModel")} +
+ + + {requests.toLocaleString()} + + + + {formatTokenAmount(totalAllTokens)} + + + + {cacheHitRate}% + +
+
+
+
{formatCurrency(cost, currencyCode)}
+
({costPercentage}%)
+
-
+ + + + + + + {model || t("unknownModel")} + + +
+
+
+
+ + {t("modal.requests")} +
+
{requests.toLocaleString()}
+
+ +
+
+ + {t("modal.totalTokens")} +
+
+ {formatTokenAmount(totalAllTokens)} +
+
+ +
+
+ + {t("modal.cost")} +
+
+ {formatCurrency(cost, currencyCode)} +
+
+
+ + + +
+

+ + {t("modal.totalTokens")} +

+
+
+
+ + {t("modal.inputTokens")} +
+
+ {formatTokenAmount(inputTokens)} +
+
+ +
+
+ + {t("modal.outputTokens")} +
+
+ {formatTokenAmount(outputTokens)} +
+
+
+
+ + + +
+

+ + {t("modal.cacheTokens")} +

+
+
+
+ + {t("modal.cacheWrite")} +
+
+ {formatTokenAmount(cacheCreationTokens)} +
+
+ +
+
+ + {t("modal.cacheRead")} +
+
+ {formatTokenAmount(cacheReadTokens)} +
+
+
+ +
+
+
+ + {t("modal.cacheHitRate")} +
+
+ + {cacheHitRate}% + + = 85 + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + }`} + > + + {cacheHitRateNum >= 85 + ? t("modal.performanceHigh") + : cacheHitRateNum >= 60 + ? t("modal.performanceMedium") + : t("modal.performanceLow")} + +
+
+
+
+
+
+
+ ); } diff --git a/src/app/[locale]/my-usage/page.tsx b/src/app/[locale]/my-usage/page.tsx index 6d7e6c4b0..366adc893 100644 --- a/src/app/[locale]/my-usage/page.tsx +++ b/src/app/[locale]/my-usage/page.tsx @@ -55,13 +55,7 @@ export default function MyUsagePage() { return (
- + {/* Provider Group and Expiration info */} {quota ? ( @@ -80,12 +74,7 @@ export default function MyUsagePage() {
) : null} - + diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 395d1a7ef..0e96bf0d3 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -53,6 +53,9 @@ export interface ProviderCacheHitRateLeaderboardEntry { cacheReadTokens: number; totalCost: number; cacheCreationCost: number; + /** Input tokens only (input + cacheCreation + cacheRead) for cache hit rate denominator */ + totalInputTokens: number; + /** @deprecated Use totalInputTokens instead */ totalTokens: number; cacheHitRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比 } @@ -427,7 +430,7 @@ async function findProviderLeaderboardWithTimezone( * * 计算规则: * - 仅统计需要缓存的请求(cache_creation_input_tokens 与 cache_read_input_tokens 不同时为 0/null) - * - 命中率 = cache_read / (input + output + cache_creation + cache_read) + * - 命中率 = cache_read / (input + cache_creation + cache_read) */ async function findProviderCacheHitRateLeaderboardWithTimezone( period: LeaderboardPeriod, @@ -435,9 +438,8 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( dateRange?: DateRangeParams, providerType?: ProviderType ): Promise { - const totalTokensExpr = sql`( + const totalInputTokensExpr = sql`( COALESCE(${messageRequest.inputTokens}, 0) + - COALESCE(${messageRequest.outputTokens}, 0) + COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + COALESCE(${messageRequest.cacheReadInputTokens}, 0) )`; @@ -447,12 +449,12 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( OR COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0 )`; - const sumTotalTokens = sql`COALESCE(sum(${totalTokensExpr})::double precision, 0::double precision)`; + const sumTotalInputTokens = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; const sumCacheReadTokens = sql`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; const sumCacheCreationCost = sql`COALESCE(sum(CASE WHEN COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0 THEN ${messageRequest.costUsd} ELSE 0 END), 0)`; const cacheHitRateExpr = sql`COALESCE( - ${sumCacheReadTokens} / NULLIF(${sumTotalTokens}, 0::double precision), + ${sumCacheReadTokens} / NULLIF(${sumTotalInputTokens}, 0::double precision), 0::double precision )`; @@ -472,7 +474,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, cacheReadTokens: sumCacheReadTokens, cacheCreationCost: sumCacheCreationCost, - totalTokens: sumTotalTokens, + totalInputTokens: sumTotalInputTokens, cacheHitRate: cacheHitRateExpr, }) .from(messageRequest) @@ -493,7 +495,8 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalCost: parseFloat(entry.totalCost), cacheReadTokens: entry.cacheReadTokens, cacheCreationCost: parseFloat(entry.cacheCreationCost), - totalTokens: entry.totalTokens, + totalInputTokens: entry.totalInputTokens, + totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1), })); } From 1cd0877ef256fd6de1ef1fe1cb50f6c1840e91f7 Mon Sep 17 00:00:00 2001 From: hwa Date: Mon, 19 Jan 2026 12:54:35 +0800 Subject: [PATCH 09/24] fix: Set HOSTNAME environment variable in Dockerfile (#622) Add HOSTNAME environment variable for container --- deploy/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d067fad6f..2c6596280 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -32,6 +32,7 @@ FROM node:trixie-slim AS runner ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +ENV HOSTNAME=0.0.0.0 WORKDIR /app # 安装 PostgreSQL 18 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查) From 09901db7c4c9d13c2c0284cbe17ac79978f6cc2a Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:07:37 +0800 Subject: [PATCH 10/24] fix: inherit 1M flag from client Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/lib/special-attributes/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/special-attributes/index.ts b/src/lib/special-attributes/index.ts index 7ea28f01c..f252c7355 100644 --- a/src/lib/special-attributes/index.ts +++ b/src/lib/special-attributes/index.ts @@ -86,8 +86,8 @@ export function shouldApplyContext1m( return isContext1mSupportedModel(model); } - // Default (inherit): passthrough client headers without modification - return false; + // Default (inherit): follow client request for supported models + return clientRequestedContext1m && isContext1mSupportedModel(model); } /** From 24aa51324a2a58af0b7668fefbea3d0688d35d35 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 11/24] fix(my-usage): make date range DST-safe Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/actions/my-usage.ts | 35 +++++++- .../actions/my-usage-date-range-dst.test.ts | 89 +++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/unit/actions/my-usage-date-range-dst.test.ts diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 4bb384e53..b8da2b24d 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -34,14 +34,41 @@ function parseDateRangeInServerTimezone( endDate?: string ): { startTime?: number; endTime?: number } { const timezone = getEnvConfig().TZ; - const parsedStart = startDate - ? fromZonedTime(`${startDate}T00:00:00`, timezone).getTime() + + const toIsoDate = (dateStr: string): { ok: true; value: string } | { ok: false } => { + return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? { ok: true, value: dateStr } : { ok: false }; + }; + + const addIsoDays = (dateStr: string, days: number): string => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!match) { + return dateStr; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + const next = new Date(Date.UTC(year, month - 1, day)); + next.setUTCDate(next.getUTCDate() + days); + return next.toISOString().slice(0, 10); + }; + + const startIso = startDate ? toIsoDate(startDate) : { ok: false as const }; + const endIso = endDate ? toIsoDate(endDate) : { ok: false as const }; + + const parsedStart = startIso.ok + ? fromZonedTime(`${startIso.value}T00:00:00`, timezone).getTime() + : Number.NaN; + + const endExclusiveDate = endIso.ok ? addIsoDays(endIso.value, 1) : null; + const parsedEndExclusive = endExclusiveDate + ? fromZonedTime(`${endExclusiveDate}T00:00:00`, timezone).getTime() : Number.NaN; - const parsedEnd = endDate ? fromZonedTime(`${endDate}T00:00:00`, timezone).getTime() : Number.NaN; return { startTime: Number.isFinite(parsedStart) ? parsedStart : undefined, - endTime: Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined, + endTime: Number.isFinite(parsedEndExclusive) ? parsedEndExclusive : undefined, }; } diff --git a/tests/unit/actions/my-usage-date-range-dst.test.ts b/tests/unit/actions/my-usage-date-range-dst.test.ts new file mode 100644 index 000000000..f8b5bc40b --- /dev/null +++ b/tests/unit/actions/my-usage-date-range-dst.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { fromZonedTime } from "date-fns-tz"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getSystemSettings: vi.fn(), + findUsageLogsWithDetails: vi.fn(), + getEnvConfig: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +vi.mock("@/repository/usage-logs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findUsageLogsWithDetails: mocks.findUsageLogsWithDetails, + }; +}); + +vi.mock("@/lib/config", () => ({ + getEnvConfig: mocks.getEnvConfig, +})); + +describe("my-usage date range parsing", () => { + it("computes exclusive endTime as next local midnight across DST start", async () => { + const tz = "America/Los_Angeles"; + mocks.getEnvConfig.mockReturnValue({ TZ: tz }); + + mocks.getSession.mockResolvedValue({ + key: { id: 1, key: "k" }, + user: { id: 1 }, + }); + + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "original", + }); + + mocks.findUsageLogsWithDetails.mockResolvedValue({ logs: [], total: 0 }); + + const { getMyUsageLogs } = await import("@/actions/my-usage"); + const res = await getMyUsageLogs({ startDate: "2024-03-10", endDate: "2024-03-10" }); + + expect(res.ok).toBe(true); + expect(mocks.findUsageLogsWithDetails).toHaveBeenCalledTimes(1); + + const args = mocks.findUsageLogsWithDetails.mock.calls[0]?.[0]; + expect(args.startTime).toBe(fromZonedTime("2024-03-10T00:00:00", tz).getTime()); + expect(args.endTime).toBe(fromZonedTime("2024-03-11T00:00:00", tz).getTime()); + + expect(args.endTime - args.startTime).toBe(23 * 60 * 60 * 1000); + }); + + it("computes exclusive endTime as next local midnight across DST end", async () => { + const tz = "America/Los_Angeles"; + mocks.getEnvConfig.mockReturnValue({ TZ: tz }); + + mocks.getSession.mockResolvedValue({ + key: { id: 1, key: "k" }, + user: { id: 1 }, + }); + + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "original", + }); + + mocks.findUsageLogsWithDetails.mockResolvedValue({ logs: [], total: 0 }); + + const { getMyUsageLogs } = await import("@/actions/my-usage"); + const res = await getMyUsageLogs({ startDate: "2024-11-03", endDate: "2024-11-03" }); + + expect(res.ok).toBe(true); + expect(mocks.findUsageLogsWithDetails).toHaveBeenCalledTimes(1); + + const args = mocks.findUsageLogsWithDetails.mock.calls[0]?.[0]; + expect(args.startTime).toBe(fromZonedTime("2024-11-03T00:00:00", tz).getTime()); + expect(args.endTime).toBe(fromZonedTime("2024-11-04T00:00:00", tz).getTime()); + + expect(args.endTime - args.startTime).toBe(25 * 60 * 60 * 1000); + }); +}); From 23057360b3765f0556adddd847e4a8f16aa9c8d8 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 12/24] test(api): cover leaderboard comma-list parsing Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- tests/unit/api/leaderboard-route.test.ts | 86 ++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/unit/api/leaderboard-route.test.ts diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts new file mode 100644 index 000000000..ce37a4823 --- /dev/null +++ b/tests/unit/api/leaderboard-route.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getLeaderboardWithCache: vi.fn(), + getSystemSettings: vi.fn(), + formatCurrency: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +vi.mock("@/lib/utils", () => ({ + formatCurrency: mocks.formatCurrency, +})); + +vi.mock("@/lib/redis", () => ({ + getLeaderboardWithCache: mocks.getLeaderboardWithCache, +})); + +describe("GET /api/leaderboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.formatCurrency.mockImplementation((val: number) => String(val)); + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + allowGlobalUsageView: true, + }); + mocks.getLeaderboardWithCache.mockResolvedValue([]); + }); + + it("returns 401 when session is missing", async () => { + mocks.getSession.mockResolvedValue(null); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = "http://localhost/api/leaderboard"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(401); + }); + + it("parses and trims userTags/userGroups and caps at 20 items", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + + const tags = Array.from({ length: 25 }, (_, i) => ` t${i} `).join(","); + const groups = " a, ,b ,c, "; + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = `http://localhost/api/leaderboard?scope=user&period=daily&userTags=${encodeURIComponent( + tags + )}&userGroups=${encodeURIComponent(groups)}`; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(200); + + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + + const options = callArgs[4]; + expect(options.userTags).toHaveLength(20); + expect(options.userTags?.[0]).toBe("t0"); + expect(options.userGroups).toEqual(["a", "b", "c"]); + }); + + it("does not apply userTags/userGroups when scope is not user", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=provider&period=daily&userTags=a&userGroups=b"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(200); + + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + const options = callArgs[4]; + expect(options.userTags).toBeUndefined(); + expect(options.userGroups).toBeUndefined(); + }); +}); From e37b8d371ce41a52042aee60250c0a2d1dce9668 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 13/24] fix(auth): avoid hardcoded server error fallback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/app/api/auth/login/route.ts | 35 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index cc09adb16..00bdd886e 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -31,20 +31,41 @@ function getLocaleFromRequest(request: NextRequest): Locale { return defaultLocale; } +async function getAuthErrorTranslations(locale: Locale) { + try { + return await getTranslations({ locale, namespace: "auth.errors" }); + } catch (error) { + logger.warn("Login route: failed to load auth.errors translations", { + locale, + error: error instanceof Error ? error.message : String(error), + }); + + try { + return await getTranslations({ locale: defaultLocale, namespace: "auth.errors" }); + } catch (fallbackError) { + logger.error("Login route: failed to load default auth.errors translations", { + locale: defaultLocale, + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), + }); + return null; + } + } +} + export async function POST(request: NextRequest) { const locale = getLocaleFromRequest(request); + const t = await getAuthErrorTranslations(locale); try { - const t = await getTranslations({ locale, namespace: "auth.errors" }); const { key } = await request.json(); if (!key) { - return NextResponse.json({ error: t("apiKeyRequired") }, { status: 400 }); + return NextResponse.json({ error: t?.("apiKeyRequired") }, { status: 400 }); } const session = await validateKey(key, { allowReadOnlyAccess: true }); if (!session) { - return NextResponse.json({ error: t("apiKeyInvalidOrExpired") }, { status: 401 }); + return NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 }); } // 设置认证 cookie @@ -64,11 +85,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { logger.error("Login error:", error); - try { - const t = await getTranslations({ locale, namespace: "auth.errors" }); - return NextResponse.json({ error: t("serverError") }, { status: 500 }); - } catch { - return NextResponse.json({ error: "Server error" }, { status: 500 }); - } + + return NextResponse.json({ error: t?.("serverError") }, { status: 500 }); } } From b72edd739fb09d3871450e2d0c7adcb9df8c0681 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 14/24] fix(actions): localize key quota permission errors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/actions/key-quota.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index a974ee88f..39e5ec604 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -1,6 +1,7 @@ "use server"; import { and, eq, isNull } from "drizzle-orm"; +import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; import { keys as keysTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; @@ -8,6 +9,7 @@ import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit/service"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; import { getSystemSettings } from "@/repository/system-config"; import { getTotalUsageForKey } from "@/repository/usage-logs"; import type { ActionResult } from "./types"; @@ -27,9 +29,24 @@ export interface KeyQuotaUsageResult { } export async function getKeyQuotaUsage(keyId: number): Promise> { + let tError: ((key: string, params?: Record) => string) | null = null; + try { + tError = await getTranslations("errors"); + } catch (error) { + logger.warn("[key-quota] failed to load errors translations", { + error: error instanceof Error ? error.message : String(error), + }); + } + try { const session = await getSession({ allowReadOnlyAccess: true }); - if (!session) return { ok: false, error: "Unauthorized" }; + if (!session) { + return { + ok: false, + error: tError?.("UNAUTHORIZED") ?? "", + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } const [keyRow] = await db .select() @@ -38,12 +55,20 @@ export async function getKeyQuotaUsage(keyId: number): Promise Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 15/24] docs: clarify context 1M inherit behavior Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/lib/special-attributes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/special-attributes/index.ts b/src/lib/special-attributes/index.ts index f252c7355..607e2a2ff 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': Passthrough client headers without modification (default) + * - 'inherit': Follow client request and passthrough headers for supported models (default) * - 'force_enable': Force enable 1M context for supported models * - 'disabled': Disable 1M context even if client requests it */ From 62e0c69055a613a9bc947241e1857861dafba918 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 16/24] test(settings): fix model multi-select messages loader Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../providers/model-multi-select-custom-models-ui.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0bdcb80cb..d6aacfedc 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 @@ -66,7 +66,7 @@ describe("ModelMultiSelect: 自定义白名单模型应可在列表中取消选 }); test("已选中但不在 availableModels 的模型应出现在列表中,并可取消选中删除", async () => { - const messages = loadTestMessages("en"); + const messages = loadMessages(); const onChange = vi.fn(); const { unmount } = render( From 8884820e45c868996c4c182c32e817201bb35c1d Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 17/24] test(dashboard): provide QueryClientProvider in edit key form test Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../edit-key-form-expiry-clear-ui.test.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/unit/dashboard/edit-key-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/edit-key-form-expiry-clear-ui.test.tsx index b40f36b95..7f6ef3298 100644 --- a/tests/unit/dashboard/edit-key-form-expiry-clear-ui.test.tsx +++ b/tests/unit/dashboard/edit-key-form-expiry-clear-ui.test.tsx @@ -1,12 +1,9 @@ -/** - * @vitest-environment happy-dom - */ - import fs from "node:fs"; import path from "node:path"; import type { ReactNode } from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { Dialog } from "@/components/ui/dialog"; @@ -53,8 +50,12 @@ function render(node: ReactNode) { document.body.appendChild(container); const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + act(() => { - root.render(node); + root.render({node}); }); return { @@ -127,10 +128,10 @@ describe("EditKeyForm: 清除 expiresAt 后应携带 expiresAt 字段提交( }); expect(keysActionMocks.editKey).toHaveBeenCalledTimes(1); - const [, payload] = keysActionMocks.editKey.mock.calls[0] as [number, any]; + const call = keysActionMocks.editKey.mock.calls[0] as unknown as [number, any]; + const [, payload] = call; - // 关键点:必须显式携带 expiresAt 字段(清除时通常为 ""),后端才会识别为“清除” - expect(Object.hasOwn(payload, "expiresAt")).toBe(true); + expect("expiresAt" in payload).toBe(true); unmount(); }); From f625975aab849a74994b560f0eb657de2ba38aa4 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 18/24] chore(scripts): avoid hardcoded docker container names Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- scripts/cleanup-test-users.sh | 24 +++++++++++------------ scripts/run-e2e-tests.sh | 36 ++++++++++++++++++----------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/scripts/cleanup-test-users.sh b/scripts/cleanup-test-users.sh index b08015576..e46d50b84 100644 --- a/scripts/cleanup-test-users.sh +++ b/scripts/cleanup-test-users.sh @@ -1,10 +1,10 @@ #!/bin/bash # 清理测试用户脚本 -echo "🔍 检查测试用户数量..." +echo "检查测试用户数量..." # 统计测试用户 -docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " +docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT COUNT(*) as 测试用户数量 FROM users @@ -13,8 +13,8 @@ WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') " echo "" -echo "📋 预览将要删除的用户(前 10 个)..." -docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " +echo "预览将要删除的用户(前 10 个)..." +docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT id, name, created_at FROM users WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') @@ -24,13 +24,13 @@ LIMIT 10; " echo "" -read -p "⚠️ 确认删除这些测试用户吗?(y/N): " confirm +read -p "确认删除这些测试用户吗?(y/N): " confirm if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then - echo "🗑️ 开始清理..." + echo "开始清理..." # 软删除关联的 keys - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " UPDATE keys SET deleted_at = NOW(), updated_at = NOW() WHERE user_id IN ( @@ -42,19 +42,19 @@ if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then " # 软删除测试用户 - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') AND deleted_at IS NULL; " - echo "✅ 清理完成!" + echo "清理完成!" echo "" - echo "📊 剩余用户统计:" - docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c " + echo "剩余用户统计:" + docker compose -f docker-compose.dev.yaml exec -T postgres psql -U postgres -d claude_code_hub -c " SELECT COUNT(*) as 总用户数 FROM users WHERE deleted_at IS NULL; " else - echo "❌ 取消清理" + echo "取消清理" fi diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh index 891155a25..d0bbc2b7d 100644 --- a/scripts/run-e2e-tests.sh +++ b/scripts/run-e2e-tests.sh @@ -12,19 +12,21 @@ set -e # 遇到错误立即退出 -echo "🚀 E2E 测试运行脚本" +echo "E2E 测试运行脚本" echo "====================" echo "" # ==================== 1. 检查数据库连接 ==================== -echo "🔍 检查数据库连接..." -if docker ps | grep -q claude-code-hub-db-dev; then - echo "✅ PostgreSQL 已运行" +COMPOSE_FILE="docker-compose.dev.yaml" + +echo "检查数据库连接..." +if [ -n "$(docker compose -f "$COMPOSE_FILE" ps -q --status running postgres 2>/dev/null)" ]; then + echo "PostgreSQL 已运行" else - echo "❌ PostgreSQL 未运行,正在启动..." - docker compose up -d postgres redis - echo "⏳ 等待数据库启动..." + echo "PostgreSQL 未运行,正在启动..." + docker compose -f "$COMPOSE_FILE" up -d postgres redis + echo "等待数据库启动..." sleep 5 fi @@ -32,14 +34,14 @@ echo "" # ==================== 2. 启动开发服务器 ==================== -echo "🚀 启动 Next.js 开发服务器..." +echo "启动 Next.js 开发服务器..." # 后台启动服务器 PORT=13500 bun run dev > /tmp/nextjs-dev.log 2>&1 & SERVER_PID=$! -echo " 服务器 PID: $SERVER_PID" -echo "⏳ 等待服务器就绪..." +echo "服务器 PID: $SERVER_PID" +echo "等待服务器就绪..." # 等待服务器启动(最多等待 60 秒) TIMEOUT=60 @@ -47,7 +49,7 @@ COUNTER=0 while [ $COUNTER -lt $TIMEOUT ]; do if curl -s http://localhost:13500/api/actions/health > /dev/null 2>&1; then - echo "✅ 服务器已就绪" + echo "服务器已就绪" break fi @@ -58,7 +60,7 @@ done if [ $COUNTER -eq $TIMEOUT ]; then echo "" - echo "❌ 服务器启动超时" + echo "服务器启动超时" kill $SERVER_PID 2>/dev/null || true exit 1 fi @@ -67,7 +69,7 @@ echo "" # ==================== 3. 运行 E2E 测试 ==================== -echo "🧪 运行 E2E 测试..." +echo "运行 E2E 测试..." echo "" # 设置环境变量 @@ -83,19 +85,19 @@ echo "" # ==================== 4. 清理并停止服务器 ==================== -echo "🧹 停止开发服务器..." +echo "停止开发服务器..." kill $SERVER_PID 2>/dev/null || true wait $SERVER_PID 2>/dev/null || true -echo "✅ 服务器已停止" +echo "服务器已停止" echo "" # ==================== 5. 输出测试结果 ==================== if [ $TEST_EXIT_CODE -eq 0 ]; then - echo "✅ E2E 测试全部通过" + echo "E2E 测试全部通过" exit 0 else - echo "❌ E2E 测试失败" + echo "E2E 测试失败" exit $TEST_EXIT_CODE fi From 17fad8ba01518b70a68e3ac9ad9e4751a2af8fb5 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 19/24] fix(i18n): replace fullwidth parentheses in ja dashboard Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- messages/ja/dashboard.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index e86cac34e..071fe6dcd 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -760,7 +760,7 @@ "limitTotalUsd": { "label": "総消費上限 (USD)", "placeholder": "空白の場合は無制限", - "description": "累計消費上限(リセットなし)", + "description": "累計消費上限(リセットなし)", "descriptionWithUserLimit": "ユーザーの総上限を超えることはできません ({limit})" }, "limitConcurrentSessions": { @@ -1205,7 +1205,7 @@ "neverExpires": "無期限", "expired": "期限切れ", "quickExtensionLabel": "クイック延長", - "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)", + "quickExtensionHint": "現在の有効期限から延長(期限切れの場合は現在から)", "customDateLabel": "有効期限を設定", "customDateHint": "有効期限を直接指定", "quickOptions": { From dab9b5074af57897b3babfc149e63e2022d04735 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:27 +0800 Subject: [PATCH 20/24] fix(ui): add a11y label and clean up dialog styles Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx | 2 +- .../[locale]/dashboard/_components/user/user-key-table-row.tsx | 2 +- .../dashboard/_components/user/user-management-table.tsx | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) 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 af51746b2..f1afa32db 100644 --- a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx @@ -70,7 +70,7 @@ export function AddKeyDialog({ return ( - + {generatedKey ? ( <> diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 68a7ffb1f..34330864b 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -218,7 +218,7 @@ export function UserKeyTableRow({ return; } toast.success(checked ? tUserStatus("userEnabled") : tUserStatus("userDisabled")); - // Инвалидировать кэш React Query для всех фильтров + // 使 React Query 缓存失效,确保数据刷新 queryClient.invalidateQueries({ queryKey: ["users"] }); // 刷新服务端数据 router.refresh(); diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx index f0e2b429e..0c6601941 100644 --- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx @@ -435,6 +435,7 @@ export function UserManagementTable({ onClick={onRefresh} disabled={isRefreshing} title={tUserMgmt("table.refresh")} + aria-label={tUserMgmt("table.refresh")} > From 4279202299699b106bf9823553b2a040c0cefa25 Mon Sep 17 00:00:00 2001 From: Ding Date: Mon, 19 Jan 2026 15:34:39 +0800 Subject: [PATCH 21/24] chore: ignore tmp scratch directory Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index eb4139039..09d22e7d9 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ TRANSLATIONS_CHECKLIST.md # docs-site submodule build artifacts docs-site/.next/ docs-site/node_modules/ + +# local scratch +tmp/ From d9e85acdac505cc3abb888b774beea56b7053ea5 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:24:40 +0800 Subject: [PATCH 22/24] =?UTF-8?q?feat:=20Dashboard=20Logs=EF=BC=9A?= =?UTF-8?q?=E7=A7=92=E7=BA=A7=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89=20+=20Se?= =?UTF-8?q?ssion=20ID=20=E7=B2=BE=E7=A1=AE=E7=AD=9B=E9=80=89/=E8=81=94?= =?UTF-8?q?=E6=83=B3/=E5=B1=95=E7=A4=BA=EF=BC=88=E5=90=AB=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E4=BF=AE=E5=A4=8D=EF=BC=89=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sessionId filter for usage logs * feat: add seconds-level time filters for logs * feat: wire sessionId into logs URL filters * chore: add i18n keys for logs sessionId * feat: add sessionId column to logs tables * feat: add sessionId suggestions for logs * docs: document dashboard logs call chain * test: add logs sessionId/time filter coverage config * fix: keep sessionId search input focused * fix: drop leaked page param on logs apply * fix: reload sessionId suggestions on scope change * fix: harden logs url params and time parsing * fix: avoid keys join for sessionId suggestions * test: strengthen empty sessionId filter assertions * chore: format logs sessionId suggestions test * Update src/actions/usage-logs.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat: 补充公共静态变量 SESSION_ID_SUGGESTION_LIMIT * fix: use prefix LIKE for sessionId suggestions * refactor: centralize usage logs sessionId suggestion constants * refactor: simplify logs url filters parsing * refactor: reuse clipboard util for sessionId copy * chore(db): add sessionId prefix index * docs: clarify sessionId suggestion semantics * test: add escapeLike unit tests * chore: apply biome fixes for sessionId search * feat: include session id in error responses * test: add coverage suite for session id errors * docs: add guide for error session id * chore: format code (feat-logs-sessionid-time-filter-233f96a) --- .gitignore | 2 + docs/dashboard-logs-callchain.md | 119 + docs/error-session-id-guide.md | 26 + drizzle/0055_neat_stepford_cuckoos.sql | 1 + drizzle/meta/0055_snapshot.json | 2404 +++++++++++++++++ drizzle/meta/_journal.json | 7 + 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 + package.json | 2 + src/actions/usage-logs.ts | 53 + .../logs/_components/usage-logs-filters.tsx | 287 +- .../_components/usage-logs-stats-panel.tsx | 1 + .../_components/usage-logs-table.test.tsx | 56 + .../logs/_components/usage-logs-table.tsx | 44 +- .../usage-logs-view-virtualized.tsx | 82 +- .../logs/_components/usage-logs-view.tsx | 57 +- .../_components/virtualized-logs-table.tsx | 50 +- .../dashboard/logs/_utils/logs-query.ts | 93 + .../dashboard/logs/_utils/time-range.ts | 49 + .../v1/_lib/codex/chat-completions-handler.ts | 28 +- src/app/v1/_lib/proxy-handler.ts | 8 +- src/app/v1/_lib/proxy/error-handler.ts | 62 +- src/app/v1/_lib/proxy/error-session-id.ts | 56 + src/drizzle/schema.ts | 2 + src/lib/constants/usage-logs.constants.ts | 3 + src/lib/utils/clipboard.ts | 50 +- src/repository/_shared/like.ts | 3 + src/repository/usage-logs.ts | 79 + ...dashboard-logs-filters-time-range.test.tsx | 128 + tests/unit/dashboard-logs-query-utils.test.ts | 90 + ...ard-logs-sessionid-suggestions-ui.test.tsx | 322 +++ .../dashboard-logs-time-range-utils.test.ts | 46 + .../constants/usage-logs.constants.test.ts | 15 + tests/unit/lib/utils/clipboard.test.ts | 119 + ...completions-handler-guard-pipeline.test.ts | 22 + .../error-handler-session-id-error.test.ts | 24 + .../proxy-handler-session-id-error.test.ts | 207 ++ tests/unit/proxy/responses-session-id.test.ts | 75 + tests/unit/repository/escape-like.test.ts | 23 + .../usage-logs-sessionid-filter.test.ts | 309 +++ .../usage-logs-sessionid-suggestions.test.ts | 204 ++ vitest.include-session-id-in-errors.config.ts | 59 + vitest.logs-sessionid-time-filter.config.ts | 61 + vitest.usage-logs-sessionid-search.config.ts | 60 + 47 files changed, 5225 insertions(+), 183 deletions(-) create mode 100644 docs/dashboard-logs-callchain.md create mode 100644 docs/error-session-id-guide.md create mode 100644 drizzle/0055_neat_stepford_cuckoos.sql create mode 100644 drizzle/meta/0055_snapshot.json create mode 100644 src/app/[locale]/dashboard/logs/_utils/logs-query.ts create mode 100644 src/app/[locale]/dashboard/logs/_utils/time-range.ts create mode 100644 src/app/v1/_lib/proxy/error-session-id.ts create mode 100644 src/lib/constants/usage-logs.constants.ts create mode 100644 src/repository/_shared/like.ts create mode 100644 tests/unit/dashboard-logs-filters-time-range.test.tsx create mode 100644 tests/unit/dashboard-logs-query-utils.test.ts create mode 100644 tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx create mode 100644 tests/unit/dashboard-logs-time-range-utils.test.ts create mode 100644 tests/unit/lib/constants/usage-logs.constants.test.ts create mode 100644 tests/unit/lib/utils/clipboard.test.ts create mode 100644 tests/unit/proxy/error-handler-session-id-error.test.ts create mode 100644 tests/unit/proxy/proxy-handler-session-id-error.test.ts create mode 100644 tests/unit/proxy/responses-session-id.test.ts create mode 100644 tests/unit/repository/escape-like.test.ts create mode 100644 tests/unit/repository/usage-logs-sessionid-filter.test.ts create mode 100644 tests/unit/repository/usage-logs-sessionid-suggestions.test.ts create mode 100644 vitest.include-session-id-in-errors.config.ts create mode 100644 vitest.logs-sessionid-time-filter.config.ts create mode 100644 vitest.usage-logs-sessionid-search.config.ts diff --git a/.gitignore b/.gitignore index 09d22e7d9..e927f4fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ /coverage-my-usage /coverage-proxy-guard-pipeline /coverage-thinking-signature-rectifier +/coverage-logs-sessionid-time-filter +/coverage-usage-logs-sessionid-search # next.js /.next/ diff --git a/docs/dashboard-logs-callchain.md b/docs/dashboard-logs-callchain.md new file mode 100644 index 000000000..da37381b7 --- /dev/null +++ b/docs/dashboard-logs-callchain.md @@ -0,0 +1,119 @@ +# Dashboard Logs(Usage Logs)入口与调用链盘点 + +本文用于锁定 `/dashboard/logs` 的真实入口与关键调用链边界,避免后续需求实现与验收口径跑偏。 + +## 1) 路由入口(Server) + +- 路由:`/dashboard/logs` +- 入口页面:`src/app/[locale]/dashboard/logs/page.tsx` + - 登录态校验:`getSession()`(未登录重定向到 `/login`) + - 数据区块入口:`UsageLogsDataSection`(`src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx`) + +## 2) 真实渲染链路(Client) + +当前页面实际使用“虚拟列表”链路: + +- 虚拟列表入口:`UsageLogsViewVirtualized`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx`) + - URL -> filters 解析:`parseLogsUrlFilters()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - filters -> URL 回填:`buildLogsUrlQuery()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - Filters 面板:`UsageLogsFilters` + - 列表:`VirtualizedLogsTable` + - 统计面板:`UsageLogsStatsPanel` + +仓库内仍存在“非虚拟表格”实现(目前不被路由引用,属于历史/备用路径): + +- `UsageLogsView`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx`) +- `UsageLogsTable`(`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx`) + +## 3) 过滤器 / URL / 时间语义 + +- URL 参数解析/构建(统一入口):`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` + - `sessionId`:字符串(trim 后空值不落盘) + - `startTime/endTime`:毫秒时间戳 +- 秒级时间工具:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` + - UI endTime 为“包含式”秒;对后端转换为“排他上界”(`endExclusive = endInclusive + 1s`) + - 后端查询语义保持:`created_at >= startTime` 且 `created_at < endTime` + +## 4) 数据获取链路(Actions -> Repository) + +### 列表(无限滚动) + +- Action:`src/actions/usage-logs.ts#getUsageLogsBatch` +- Repo:`src/repository/usage-logs.ts#findUsageLogsBatch` + +### 统计(折叠面板按需加载) + +- Action:`src/actions/usage-logs.ts#getUsageLogsStats` +- Repo:`src/repository/usage-logs.ts#findUsageLogsStats` + +### 导出 CSV + +- Action:`src/actions/usage-logs.ts#exportUsageLogs` +- Repo:`src/repository/usage-logs.ts#findUsageLogsWithDetails` +- CSV 生成:`src/actions/usage-logs.ts#generateCsv` + +### Session ID 联想(候选查询) + +- Action:`src/actions/usage-logs.ts#getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts#findUsageLogSessionIdSuggestions` + +#### 匹配语义与边界(2026-01-15 更新) + +- **前端约束**: + - 最小长度:`SESSION_ID_SUGGESTION_MIN_LEN`(`src/lib/constants/usage-logs.constants.ts`) + - 最大长度截断:`SESSION_ID_SUGGESTION_MAX_LEN`(`src/actions/usage-logs.ts` 内对输入 trim 后截断) + - 每次返回数量:`SESSION_ID_SUGGESTION_LIMIT` +- **后端匹配**: + - 语义:仅支持「字面量前缀匹配」(`term%`),不再支持包含匹配(`%term%`) + - 安全:输入中的 `%` / `_` / `\\` 会被统一转义,避免被当作 LIKE 通配符 + - SQL(核心条件):`session_id LIKE '%' ESCAPE '\\'` + - 转义实现:`src/repository/_shared/like.ts#escapeLike` +- **行为变更示例**: + - 之前:输入 `abc` 可能命中 `xxxabcxxx`(包含匹配) + - 之后:仅命中 `abc...`(前缀匹配) + - 之前:输入 `%` / `_` 可主动触发通配 + - 之后:`%` / `_` 按字面量处理(例如输入 `%a` 只匹配以 `%a` 开头的 session_id) + +#### 索引与迁移(前缀匹配性能) + +- 已有索引:`idx_message_request_session_id`(`message_request.session_id`,partial: `deleted_at IS NULL`) +- 新增索引(前缀匹配):`idx_message_request_session_id_prefix` + - opclass:`varchar_pattern_ops` + - partial:`deleted_at IS NULL AND (blocked_by IS NULL OR blocked_by <> 'warmup')` + - 迁移文件:`drizzle/0055_neat_stepford_cuckoos.sql` + +## 5) 本需求相关影响面(文件/符号清单) + +**前端(logs 页面内聚)**: + +- URL/过滤器:`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` +- 秒级时间:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` +- 过滤器 UI:`src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx` +- 虚拟列表:`src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx` +- 非虚拟表格:`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx` +- 统计面板:`src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx` + +**后端(Actions/Repo)**: + +- Actions:`src/actions/usage-logs.ts` + - `getUsageLogsBatch/getUsageLogsStats/exportUsageLogs/getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts` + - `findUsageLogsBatch/findUsageLogsWithDetails/findUsageLogsStats/findUsageLogSessionIdSuggestions` + +**i18n(用户可见文案)**: + +- `messages/*/dashboard.json`(`dashboard.logs.filters.*` / `dashboard.logs.columns.*`) + +## 6) 边界说明(在范围内 / 不在范围内) + +在范围内(本次需求直接相关): + +- `sessionId` 精确筛选 + URL 回填 + UI 展示(列/复制/tooltip) +- 秒级时间输入与 `endExclusive` 语义对齐(`< endTime`) +- Session ID 联想(最小成本:minLen + debounce + limit) + +不在范围内(需另开 issue/评审确认后再做): + +- 针对联想查询的索引/物化/离线表(优化类工程) +- 大规模改动数据库 schema 或重建索引策略(例如 CONCURRENTLY/离线重建) +- Logs 页面其它过滤项语义调整(非本需求验收口径) diff --git a/docs/error-session-id-guide.md b/docs/error-session-id-guide.md new file mode 100644 index 000000000..97f1c9ce8 --- /dev/null +++ b/docs/error-session-id-guide.md @@ -0,0 +1,26 @@ +# Error Session ID Guide + +When reporting an API error, include the CCH session id so maintainers can locate the exact request. + +## Where to find it + +1. **Preferred**: response header `x-cch-session-id` +2. **Fallback**: `error.message` suffix `cch_session_id: ` + +If the response does not include a session id, the server could not determine it for that request. + +## Example (curl) + +```bash +curl -i -sS \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"hi"}]}' \\ + http://localhost:13500/v1/chat/completions +``` + +In the response: + +- Check header: `x-cch-session-id: ...` +- If missing, check JSON: `{"error":{"message":"... (cch_session_id: ...)"} }` + diff --git a/drizzle/0055_neat_stepford_cuckoos.sql b/drizzle/0055_neat_stepford_cuckoos.sql new file mode 100644 index 000000000..c91fed32a --- /dev/null +++ b/drizzle/0055_neat_stepford_cuckoos.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_session_id_prefix" ON "message_request" USING btree ("session_id" varchar_pattern_ops) WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json new file mode 100644 index 000000000..939e558ff --- /dev/null +++ b/drizzle/meta/0055_snapshot.json @@ -0,0 +1,2404 @@ +{ + "id": "b40c930a-4001-4403-90b9-652a5878893c", + "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 106e43116..a4148b04e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768240715707, "tag": "0054_tidy_winter_soldier", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1768443427816, + "tag": "0055_neat_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 499e57682..fed1a3327 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "User", "provider": "Provider", + "sessionId": "Session ID", "searchUser": "Search users...", "searchProvider": "Search providers...", + "searchSessionId": "Search session IDs...", "noUserFound": "No matching users found", "noProviderFound": "No matching providers found", + "noSessionFound": "No matching session IDs found", "model": "Model", "endpoint": "Endpoint", "status": "Status", @@ -96,6 +99,7 @@ "time": "Time", "user": "User", "key": "Key", + "sessionId": "Session ID", "provider": "Provider", "model": "Billing Model", "endpoint": "Endpoint", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 071fe6dcd..57af4b2e4 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "ユーザー", "provider": "プロバイダー", + "sessionId": "セッションID", "searchUser": "ユーザーを検索...", "searchProvider": "プロバイダーを検索...", + "searchSessionId": "セッションIDを検索...", "noUserFound": "一致するユーザーが見つかりません", "noProviderFound": "一致するプロバイダーが見つかりません", + "noSessionFound": "一致するセッションIDが見つかりません", "model": "モデル", "endpoint": "エンドポイント", "status": "ステータス", @@ -96,6 +99,7 @@ "time": "時間", "user": "ユーザー", "key": "キー", + "sessionId": "セッションID", "provider": "プロバイダー", "model": "課金モデル", "endpoint": "エンドポイント", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 3abb884e3..a7ad1418d 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "Пользователь", "provider": "Поставщик", + "sessionId": "ID сессии", "searchUser": "Поиск пользователей...", "searchProvider": "Поиск провайдеров...", + "searchSessionId": "Поиск ID сессии...", "noUserFound": "Пользователи не найдены", "noProviderFound": "Провайдеры не найдены", + "noSessionFound": "ID сессии не найдены", "model": "Модель", "endpoint": "Эндпоинт", "status": "Статус", @@ -96,6 +99,7 @@ "time": "Время", "user": "Пользователь", "key": "Ключ", + "sessionId": "ID сессии", "provider": "Поставщик", "model": "Модель тарификации", "endpoint": "Эндпоинт", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 282ff6db9..9627bf0c6 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "用户", "provider": "供应商", + "sessionId": "Session ID", "searchUser": "搜索用户...", "searchProvider": "搜索供应商...", + "searchSessionId": "搜索 Session ID...", "noUserFound": "未找到匹配的用户", "noProviderFound": "未找到匹配的供应商", + "noSessionFound": "未找到匹配的 Session ID", "model": "模型", "endpoint": "端点", "status": "状态", @@ -96,6 +99,7 @@ "time": "时间", "user": "用户", "key": "密钥", + "sessionId": "Session ID", "provider": "供应商", "model": "计费模型", "endpoint": "端点", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 6bc25609b..887447252 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "使用者", "provider": "供應商", + "sessionId": "Session ID", "searchUser": "搜尋使用者...", "searchProvider": "搜尋供應商...", + "searchSessionId": "搜尋 Session ID...", "noUserFound": "未找到匹配的使用者", "noProviderFound": "未找到匹配的供應商", + "noSessionFound": "未找到匹配的 Session ID", "model": "Model", "endpoint": "端點", "status": "狀態", @@ -96,6 +99,7 @@ "time": "時間", "user": "使用者", "key": "金鑰", + "sessionId": "Session ID", "provider": "供應商", "model": "計費模型", "endpoint": "端點", diff --git a/package.json b/package.json index 8e6f5aacb..c6eae1017 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose", "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose", "test:coverage": "vitest run --coverage", + "test:coverage:logs-sessionid-time-filter": "vitest run --config vitest.logs-sessionid-time-filter.config.ts --coverage", "test:coverage:codex-session-id-completer": "vitest run --config vitest.codex-session-id-completer.config.ts --coverage", "test:coverage:thinking-signature-rectifier": "vitest run --config vitest.thinking-signature-rectifier.config.ts --coverage", "test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage", "test:coverage:my-usage": "vitest run --config vitest.my-usage.config.ts --coverage", "test:coverage:proxy-guard-pipeline": "vitest run --config vitest.proxy-guard-pipeline.config.ts --coverage", + "test:coverage:include-session-id-in-errors": "vitest run --config vitest.include-session-id-in-errors.config.ts --coverage", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml", "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e", "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js", diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 0c8a9e079..b0d612b0a 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -1,8 +1,14 @@ "use server"; import { getSession } from "@/lib/auth"; +import { + SESSION_ID_SUGGESTION_LIMIT, + SESSION_ID_SUGGESTION_MAX_LEN, + SESSION_ID_SUGGESTION_MIN_LEN, +} from "@/lib/constants/usage-logs.constants"; import { logger } from "@/lib/logger"; import { + findUsageLogSessionIdSuggestions, findUsageLogsBatch, findUsageLogsStats, findUsageLogsWithDetails, @@ -279,6 +285,53 @@ export async function getFilterOptions(): Promise> { } } +export interface UsageLogSessionIdSuggestionInput { + term: string; + userId?: number; + keyId?: number; + providerId?: number; +} + +export async function getUsageLogSessionIdSuggestions( + input: UsageLogSessionIdSuggestionInput +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + const trimmedTerm = input.term.trim().slice(0, SESSION_ID_SUGGESTION_MAX_LEN); + if (trimmedTerm.length < SESSION_ID_SUGGESTION_MIN_LEN) { + return { ok: true, data: [] }; + } + + const finalFilters = + session.user.role === "admin" + ? { + term: trimmedTerm, + userId: input.userId, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + } + : { + term: trimmedTerm, + userId: session.user.id, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + }; + + const sessionIds = await findUsageLogSessionIdSuggestions(finalFilters); + return { ok: true, data: sessionIds }; + } catch (error) { + logger.error("获取 sessionId 联想失败:", error); + const message = error instanceof Error ? error.message : "获取 sessionId 联想失败"; + return { ok: false, error: message }; + } +} + /** * 获取使用日志聚合统计(独立接口,用于可折叠面板按需加载) * diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 3d35c1e0d..540c86eac 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -1,13 +1,13 @@ "use client"; -import { addDays, format, parse } from "date-fns"; +import { format } from "date-fns"; import { Check, ChevronsUpDown, Download } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { getKeys } from "@/actions/keys"; -import { exportUsageLogs } from "@/actions/usage-logs"; +import { exportUsageLogs, getUsageLogSessionIdSuggestions } from "@/actions/usage-logs"; import { searchUsersForFilter } from "@/actions/users"; import { Button } from "@/components/ui/button"; import { @@ -20,7 +20,7 @@ import { } from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -28,6 +28,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SESSION_ID_SUGGESTION_MIN_LEN } from "@/lib/constants/usage-logs.constants"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; @@ -36,6 +37,11 @@ import { useLazyModels, useLazyStatusCodes, } from "../_hooks/use-lazy-filter-options"; +import { + dateStringWithClockToTimestamp, + formatClockFromTimestamp, + inclusiveEndTimestampFromExclusive, +} from "../_utils/time-range"; import { LogsDateRangePicker } from "./logs-date-range-picker"; // 硬编码常用状态码(首次渲染时显示,无需等待加载) @@ -51,6 +57,7 @@ interface UsageLogsFiltersProps { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; /** 开始时间戳(毫秒,浏览器本地时区的 00:00:00) */ startTime?: number; /** 结束时间戳(毫秒,浏览器本地时区的次日 00:00:00,用于 < 比较) */ @@ -125,6 +132,12 @@ export function UsageLogsFilters({ const [isExporting, setIsExporting] = useState(false); const [userPopoverOpen, setUserPopoverOpen] = useState(false); const [providerPopoverOpen, setProviderPopoverOpen] = useState(false); + const [sessionIdPopoverOpen, setSessionIdPopoverOpen] = useState(false); + const [isSessionIdsLoading, setIsSessionIdsLoading] = useState(false); + const [availableSessionIds, setAvailableSessionIds] = useState([]); + const debouncedSessionIdSearchTerm = useDebounce(localFilters.sessionId ?? "", 300); + const sessionIdSearchRequestIdRef = useRef(0); + const lastLoadedSessionIdSuggestionsKeyRef = useRef(undefined); useEffect(() => { isMountedRef.current = true; @@ -181,6 +194,84 @@ export function UsageLogsFilters({ } }, [isAdmin, userPopoverOpen]); + const loadSessionIdsForFilter = useCallback( + async (term: string) => { + const requestId = ++sessionIdSearchRequestIdRef.current; + setIsSessionIdsLoading(true); + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + lastLoadedSessionIdSuggestionsKeyRef.current = requestKey; + + try { + const result = await getUsageLogSessionIdSuggestions({ + term, + userId: isAdmin ? localFilters.userId : undefined, + keyId: localFilters.keyId, + providerId: localFilters.providerId, + }); + + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + + if (result.ok) { + setAvailableSessionIds(result.data); + } else { + console.error("Failed to load sessionId suggestions:", result.error); + setAvailableSessionIds([]); + } + } catch (error) { + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + console.error("Failed to load sessionId suggestions:", error); + setAvailableSessionIds([]); + } finally { + if (isMountedRef.current && requestId === sessionIdSearchRequestIdRef.current) { + setIsSessionIdsLoading(false); + } + } + }, + [isAdmin, localFilters.keyId, localFilters.providerId, localFilters.userId] + ); + + useEffect(() => { + if (!sessionIdPopoverOpen) return; + + const term = debouncedSessionIdSearchTerm.trim(); + if (term.length < SESSION_ID_SUGGESTION_MIN_LEN) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + return; + } + + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + if (requestKey === lastLoadedSessionIdSuggestionsKeyRef.current) return; + void loadSessionIdsForFilter(term); + }, [ + sessionIdPopoverOpen, + debouncedSessionIdSearchTerm, + isAdmin, + localFilters.userId, + localFilters.keyId, + localFilters.providerId, + loadSessionIdsForFilter, + ]); + + useEffect(() => { + if (!sessionIdPopoverOpen) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + } + }, [sessionIdPopoverOpen]); + useEffect(() => { if (initialKeys.length > 0) { setKeys(initialKeys); @@ -228,7 +319,33 @@ export function UsageLogsFilters({ }; const handleApply = () => { - onChange(localFilters); + const { + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + } = localFilters; + + onChange({ + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + }); }; const handleReset = () => { @@ -272,24 +389,28 @@ export function UsageLogsFilters({ return format(date, "yyyy-MM-dd"); }, []); - // Helper: parse date string to timestamp (start of day in browser timezone) - const dateStringToTimestamp = useCallback((dateStr: string): number => { - const [year, month, day] = dateStr.split("-").map(Number); - return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); - }, []); - // Memoized startDate for display (from timestamp) const displayStartDate = useMemo(() => { if (!localFilters.startTime) return undefined; return timestampToDateString(localFilters.startTime); }, [localFilters.startTime, timestampToDateString]); - // Memoized endDate calculation: endTime is next day 00:00, subtract 1 day to show correct end date + const displayStartClock = useMemo(() => { + if (!localFilters.startTime) return undefined; + return formatClockFromTimestamp(localFilters.startTime); + }, [localFilters.startTime]); + + // Memoized endDate calculation: endTime is exclusive, use endTime-1s to infer inclusive display end date const displayEndDate = useMemo(() => { if (!localFilters.endTime) return undefined; - // endTime is next day 00:00, so subtract 1 day to get actual end date - const actualEndDate = new Date(localFilters.endTime - 24 * 60 * 60 * 1000); - return format(actualEndDate, "yyyy-MM-dd"); + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return format(new Date(inclusiveEndTime), "yyyy-MM-dd"); + }, [localFilters.endTime]); + + const displayEndClock = useMemo(() => { + if (!localFilters.endTime) return undefined; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return formatClockFromTimestamp(inclusiveEndTime); }, [localFilters.endTime]); // Memoized callback for date range changes @@ -297,20 +418,21 @@ export function UsageLogsFilters({ (range: { startDate?: string; endDate?: string }) => { if (range.startDate && range.endDate) { // Convert to millisecond timestamps: - // startTime: start of selected start date (00:00:00.000 in browser timezone) - // endTime: start of day AFTER selected end date (for < comparison) - const startTimestamp = dateStringToTimestamp(range.startDate); - const endDate = parse(range.endDate, "yyyy-MM-dd", new Date()); - const nextDay = addDays(endDate, 1); - const endTimestamp = new Date( - nextDay.getFullYear(), - nextDay.getMonth(), - nextDay.getDate(), - 0, - 0, - 0, - 0 - ).getTime(); + // startTime: startDate + startClock (default 00:00:00) + // endTime: endDate + endClock as exclusive upper bound (endClock default 23:59:59) + const startClock = displayStartClock ?? "00:00:00"; + const endClock = displayEndClock ?? "23:59:59"; + const startTimestamp = dateStringWithClockToTimestamp(range.startDate, startClock); + const endInclusiveTimestamp = dateStringWithClockToTimestamp(range.endDate, endClock); + if (startTimestamp === undefined || endInclusiveTimestamp === undefined) { + setLocalFilters((prev) => ({ + ...prev, + startTime: undefined, + endTime: undefined, + })); + return; + } + const endTimestamp = endInclusiveTimestamp + 1000; setLocalFilters((prev) => ({ ...prev, startTime: startTimestamp, @@ -324,7 +446,7 @@ export function UsageLogsFilters({ })); } }, - [dateStringToTimestamp] + [displayEndClock, displayStartClock] ); return ( @@ -338,6 +460,56 @@ export function UsageLogsFilters({ endDate={displayEndDate} onDateRangeChange={handleDateRangeChange} /> +
+
+ + { + const nextClock = e.target.value || "00:00:00"; + setLocalFilters((prev) => { + if (!prev.startTime) return prev; + const dateStr = timestampToDateString(prev.startTime); + const startTime = dateStringWithClockToTimestamp(dateStr, nextClock); + if (startTime === undefined) return prev; + return { + ...prev, + startTime, + }; + }); + }} + /> +
+
+ + { + const nextClock = e.target.value || "23:59:59"; + setLocalFilters((prev) => { + if (!prev.endTime) return prev; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(prev.endTime); + const endDateStr = timestampToDateString(inclusiveEndTime); + const endInclusiveTimestamp = dateStringWithClockToTimestamp( + endDateStr, + nextClock + ); + if (endInclusiveTimestamp === undefined) return prev; + return { + ...prev, + endTime: endInclusiveTimestamp + 1000, + }; + }); + }} + /> +
+
{/* 用户选择(仅 Admin) */} @@ -532,6 +704,63 @@ export function UsageLogsFilters({
)} + {/* Session ID 联想 */} +
+ + + + { + const term = (localFilters.sessionId ?? "").trim(); + setSessionIdPopoverOpen(term.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + onChange={(e) => { + const next = e.target.value.trim(); + setLocalFilters((prev) => ({ ...prev, sessionId: next || undefined })); + setSessionIdPopoverOpen(next.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + /> + + e.preventDefault()} + onWheel={(e) => e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + {isSessionIdsLoading + ? t("logs.stats.loading") + : t("logs.filters.noSessionFound")} + + + {availableSessionIds.map((sessionId) => ( + { + setLocalFilters((prev) => ({ ...prev, sessionId })); + setSessionIdPopoverOpen(false); + }} + className="cursor-pointer" + > + {sessionId} + {localFilters.sessionId === sessionId && ( + + )} + + ))} + + + + + +
+ {/* 模型选择 */}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx index 113e42827..44c826698 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx @@ -17,6 +17,7 @@ interface UsageLogsStatsPanelProps { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; startTime?: number; endTime?: number; statusCode?: number; diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index b0b8e6c30..952055075 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -10,6 +10,15 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: toastMocks, +})); + vi.mock("@/components/ui/tooltip", () => ({ TooltipProvider: ({ children }: { children?: ReactNode }) =>
{children}
, Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -181,4 +190,51 @@ describe("usage-logs-table multiplier badge", () => { }); container.remove(); }); + + test("copies sessionId on click and shows toast", async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + + const root = createRoot(container); + await act(async () => { + root.render( + {}} + isPending={false} + /> + ); + }); + + const sessionBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("session_test") + ); + expect(sessionBtn).not.toBeUndefined(); + + await act(async () => { + sessionBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("session_test"); + expect(toastMocks.success).toHaveBeenCalledWith("actions.copied"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index c02b907dc..eba9c8f4b 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -1,7 +1,8 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { type MouseEvent, useCallback, useState } from "react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RelativeTime } from "@/components/ui/relative-time"; @@ -15,6 +16,7 @@ import { } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, formatTokenAmount } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -62,6 +64,18 @@ export function UsageLogsTable({ scrollToRedirect: boolean; }>({ logId: null, scrollToRedirect: false }); + const handleCopySessionIdClick = useCallback( + (event: MouseEvent) => { + const sessionId = event.currentTarget.dataset.sessionId; + if (!sessionId) return; + + void copyTextToClipboard(sessionId).then((ok) => { + if (ok) toast.success(t("actions.copied")); + }); + }, + [t] + ); + return (
@@ -71,6 +85,7 @@ export function UsageLogsTable({ {t("logs.columns.time")} {t("logs.columns.user")} {t("logs.columns.key")} + {t("logs.columns.sessionId")} {t("logs.columns.provider")} {t("logs.columns.model")} {t("logs.columns.tokens")} @@ -83,7 +98,7 @@ export function UsageLogsTable({ {logs.length === 0 ? ( - + {t("logs.table.noData")} @@ -129,6 +144,31 @@ export function UsageLogsTable({ {log.userName} {log.keyName} + + {log.sessionId ? ( + + + + + + +

+ {log.sessionId} +

+
+
+
+ ) : ( + - + )} +
{isWarmupSkipped ? ( // Warmup 被跳过的请求显示“抢答/跳过”标记 diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 396920af2..2e8d19cab 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -13,6 +13,7 @@ import type { CurrencyCode } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource, SystemSettings } from "@/types/system-config"; +import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table"; @@ -91,40 +92,33 @@ function UsageLogsViewContent({ const resolvedKeys = initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : []); // Parse filters from URL with stable reference - const filters = useMemo( - () => ({ - userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined, - keyId: searchParams.keyId ? parseInt(searchParams.keyId as string, 10) : undefined, - providerId: searchParams.providerId - ? parseInt(searchParams.providerId as string, 10) - : undefined, - startTime: searchParams.startTime - ? parseInt(searchParams.startTime as string, 10) - : undefined, - endTime: searchParams.endTime ? parseInt(searchParams.endTime as string, 10) : undefined, - statusCode: - searchParams.statusCode && searchParams.statusCode !== "!200" - ? parseInt(searchParams.statusCode as string, 10) - : undefined, - excludeStatusCode200: searchParams.statusCode === "!200", - model: searchParams.model as string | undefined, - endpoint: searchParams.endpoint as string | undefined, - minRetryCount: searchParams.minRetry - ? parseInt(searchParams.minRetry as string, 10) - : undefined, - }), - [ - searchParams.userId, - searchParams.keyId, - searchParams.providerId, - searchParams.startTime, - searchParams.endTime, - searchParams.statusCode, - searchParams.model, - searchParams.endpoint, - searchParams.minRetry, - ] - ); + const filters = useMemo(() => { + return parseLogsUrlFilters({ + userId: searchParams.userId, + keyId: searchParams.keyId, + providerId: searchParams.providerId, + sessionId: searchParams.sessionId, + startTime: searchParams.startTime, + endTime: searchParams.endTime, + statusCode: searchParams.statusCode, + model: searchParams.model, + endpoint: searchParams.endpoint, + minRetry: searchParams.minRetry, + page: searchParams.page, + }) as VirtualizedLogsTableFilters & { page?: number }; + }, [ + searchParams.userId, + searchParams.keyId, + searchParams.providerId, + searchParams.sessionId, + searchParams.startTime, + searchParams.endTime, + searchParams.statusCode, + searchParams.model, + searchParams.endpoint, + searchParams.minRetry, + searchParams.page, + ]); // Manual refresh handler const handleManualRefresh = useCallback(async () => { @@ -138,24 +132,7 @@ function UsageLogsViewContent({ // Handle filter changes const handleFilterChange = (newFilters: Omit) => { - const query = new URLSearchParams(); - - if (newFilters.userId) query.set("userId", newFilters.userId.toString()); - if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString()); - if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString()); - if (newFilters.startTime) query.set("startTime", newFilters.startTime.toString()); - if (newFilters.endTime) query.set("endTime", newFilters.endTime.toString()); - if (newFilters.excludeStatusCode200) { - query.set("statusCode", "!200"); - } else if (newFilters.statusCode !== undefined) { - query.set("statusCode", newFilters.statusCode.toString()); - } - if (newFilters.model) query.set("model", newFilters.model); - if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint); - if (newFilters.minRetryCount !== undefined) { - query.set("minRetry", newFilters.minRetryCount.toString()); - } - + const query = buildLogsUrlQuery(newFilters); router.push(`/dashboard/logs?${query.toString()}`); }; @@ -181,6 +158,7 @@ function UsageLogsViewContent({ userId: filters.userId, keyId: filters.keyId, providerId: filters.providerId, + sessionId: filters.sessionId, startTime: filters.startTime, endTime: filters.endTime, statusCode: filters.statusCode, diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx index 379f8212c..677f4e573 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx @@ -13,6 +13,7 @@ import type { UsageLogsResult } from "@/repository/usage-logs"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource } from "@/types/system-config"; +import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { UsageLogsTable } from "./usage-logs-table"; @@ -50,39 +51,8 @@ export function UsageLogsView({ // 从 URL 参数解析筛选条件 // 使用毫秒时间戳传递时间,避免时区问题 - const filters: { - userId?: number; - keyId?: number; - providerId?: number; - startTime?: number; - endTime?: number; - statusCode?: number; - excludeStatusCode200?: boolean; - model?: string; - endpoint?: string; - minRetryCount?: number; - page: number; - } = { - userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined, - keyId: searchParams.keyId ? parseInt(searchParams.keyId as string, 10) : undefined, - providerId: searchParams.providerId - ? parseInt(searchParams.providerId as string, 10) - : undefined, - // 使用毫秒时间戳,无时区歧义 - startTime: searchParams.startTime ? parseInt(searchParams.startTime as string, 10) : undefined, - endTime: searchParams.endTime ? parseInt(searchParams.endTime as string, 10) : undefined, - statusCode: - searchParams.statusCode && searchParams.statusCode !== "!200" - ? parseInt(searchParams.statusCode as string, 10) - : undefined, - excludeStatusCode200: searchParams.statusCode === "!200", - model: searchParams.model as string | undefined, - endpoint: searchParams.endpoint as string | undefined, - minRetryCount: searchParams.minRetry - ? parseInt(searchParams.minRetry as string, 10) - : undefined, - page: searchParams.page ? parseInt(searchParams.page as string, 10) : 1, - }; + const parsedFilters = parseLogsUrlFilters(searchParams); + const filters = { ...parsedFilters, page: parsedFilters.page ?? 1 } as const; // 使用 ref 来存储最新的值,避免闭包陷阱 const isPendingRef = useRef(isPending); @@ -176,25 +146,7 @@ export function UsageLogsView({ // 处理筛选条件变更 const handleFilterChange = (newFilters: Omit) => { - const query = new URLSearchParams(); - - if (newFilters.userId) query.set("userId", newFilters.userId.toString()); - if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString()); - if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString()); - // 使用毫秒时间戳传递时间,无时区歧义 - if (newFilters.startTime) query.set("startTime", newFilters.startTime.toString()); - if (newFilters.endTime) query.set("endTime", newFilters.endTime.toString()); - if (newFilters.excludeStatusCode200) { - query.set("statusCode", "!200"); - } else if (newFilters.statusCode !== undefined) { - query.set("statusCode", newFilters.statusCode.toString()); - } - if (newFilters.model) query.set("model", newFilters.model); - if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint); - if (newFilters.minRetryCount !== undefined) { - query.set("minRetry", newFilters.minRetryCount.toString()); - } - + const query = buildLogsUrlQuery(newFilters); router.push(`/dashboard/logs?${query.toString()}`); }; @@ -213,6 +165,7 @@ export function UsageLogsView({ userId: filters.userId, keyId: filters.keyId, providerId: filters.providerId, + sessionId: filters.sessionId, startTime: filters.startTime, endTime: filters.endTime, statusCode: filters.statusCode, diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index e38646a3d..6989abb0f 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -3,7 +3,8 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { ArrowUp, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { getUsageLogsBatch } from "@/actions/usage-logs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -11,6 +12,7 @@ import { RelativeTime } from "@/components/ui/relative-time"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useVirtualizer } from "@/hooks/use-virtualizer"; import { cn, formatTokenAmount } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -31,6 +33,7 @@ export interface VirtualizedLogsTableFilters { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; startTime?: number; endTime?: number; statusCode?: number; @@ -66,6 +69,18 @@ export function VirtualizedLogsTable({ scrollToRedirect: boolean; }>({ logId: null, scrollToRedirect: false }); + const handleCopySessionIdClick = useCallback( + (event: MouseEvent) => { + const sessionId = event.currentTarget.dataset.sessionId; + if (!sessionId) return; + + void copyTextToClipboard(sessionId).then((ok) => { + if (ok) toast.success(t("actions.copied")); + }); + }, + [t] + ); + // Infinite query with cursor-based pagination const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error } = useInfiniteQuery({ @@ -179,6 +194,12 @@ export function VirtualizedLogsTable({
{t("logs.columns.key")}
+
+ {t("logs.columns.sessionId")} +
+ {/* Session ID */} +
+ {log.sessionId ? ( + + + + + + +

+ {log.sessionId} +

+
+
+
+ ) : ( + - + )} +
+ {/* Provider */}
{log.blockedBy ? ( diff --git a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts new file mode 100644 index 000000000..4430f3854 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts @@ -0,0 +1,93 @@ +export interface LogsUrlFilters { + userId?: number; + keyId?: number; + providerId?: number; + sessionId?: string; + startTime?: number; + endTime?: number; + statusCode?: number; + excludeStatusCode200?: boolean; + model?: string; + endpoint?: string; + minRetryCount?: number; + page?: number; +} + +function firstString(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +function parseIntParam(value: string | string[] | undefined): number | undefined { + const raw = firstString(value); + if (!raw) return undefined; + const num = Number.parseInt(raw, 10); + return Number.isFinite(num) ? num : undefined; +} + +function parseStringParam(value: string | string[] | undefined): string | undefined { + const raw = firstString(value); + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +export function parseLogsUrlFilters(searchParams: { + [key: string]: string | string[] | undefined; +}): LogsUrlFilters { + const statusCodeParam = parseStringParam(searchParams.statusCode); + const pageRaw = parseIntParam(searchParams.page); + const page = pageRaw && pageRaw >= 1 ? pageRaw : undefined; + + const statusCode = + statusCodeParam && statusCodeParam !== "!200" + ? Number.parseInt(statusCodeParam, 10) + : undefined; + + return { + userId: parseIntParam(searchParams.userId), + keyId: parseIntParam(searchParams.keyId), + providerId: parseIntParam(searchParams.providerId), + sessionId: parseStringParam(searchParams.sessionId), + startTime: parseIntParam(searchParams.startTime), + endTime: parseIntParam(searchParams.endTime), + statusCode: Number.isFinite(statusCode) ? statusCode : undefined, + excludeStatusCode200: statusCodeParam === "!200", + model: parseStringParam(searchParams.model), + endpoint: parseStringParam(searchParams.endpoint), + minRetryCount: parseIntParam(searchParams.minRetry), + page, + }; +} + +export function buildLogsUrlQuery(filters: LogsUrlFilters): URLSearchParams { + const query = new URLSearchParams(); + + if (filters.userId !== undefined) query.set("userId", filters.userId.toString()); + if (filters.keyId !== undefined) query.set("keyId", filters.keyId.toString()); + if (filters.providerId !== undefined) query.set("providerId", filters.providerId.toString()); + + const sessionId = filters.sessionId?.trim(); + if (sessionId) query.set("sessionId", sessionId); + + if (filters.startTime !== undefined) query.set("startTime", filters.startTime.toString()); + if (filters.endTime !== undefined) query.set("endTime", filters.endTime.toString()); + + if (filters.excludeStatusCode200) { + query.set("statusCode", "!200"); + } else if (filters.statusCode !== undefined) { + query.set("statusCode", filters.statusCode.toString()); + } + + if (filters.model) query.set("model", filters.model); + if (filters.endpoint) query.set("endpoint", filters.endpoint); + + if (filters.minRetryCount !== undefined) { + query.set("minRetry", filters.minRetryCount.toString()); + } + + if (filters.page !== undefined && filters.page > 1) { + query.set("page", filters.page.toString()); + } + + return query; +} diff --git a/src/app/[locale]/dashboard/logs/_utils/time-range.ts b/src/app/[locale]/dashboard/logs/_utils/time-range.ts new file mode 100644 index 000000000..2ccf628e8 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_utils/time-range.ts @@ -0,0 +1,49 @@ +export interface ClockParts { + hours: number; + minutes: number; + seconds: number; +} + +export function parseClockString(clockStr: string): ClockParts { + const [hoursRaw, minutesRaw, secondsRaw] = clockStr.split(":"); + + const hours = Number(hoursRaw); + const minutes = Number(minutesRaw); + const seconds = Number(secondsRaw ?? "0"); + + return { + hours: Number.isFinite(hours) ? hours : 0, + minutes: Number.isFinite(minutes) ? minutes : 0, + seconds: Number.isFinite(seconds) ? seconds : 0, + }; +} + +export function formatClockFromTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const hh = `${date.getHours()}`.padStart(2, "0"); + const mm = `${date.getMinutes()}`.padStart(2, "0"); + const ss = `${date.getSeconds()}`.padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +export function dateStringWithClockToTimestamp( + dateStr: string, + clockStr: string +): number | undefined { + const [year, month, day] = dateStr.split("-").map(Number); + const { hours, minutes, seconds } = parseClockString(clockStr); + + const date = new Date(year, month - 1, day, hours, minutes, seconds, 0); + const timestamp = date.getTime(); + if (!Number.isFinite(timestamp)) return undefined; + + if (date.getFullYear() !== year) return undefined; + if (date.getMonth() !== month - 1) return undefined; + if (date.getDate() !== day) return undefined; + + return timestamp; +} + +export function inclusiveEndTimestampFromExclusive(endExclusiveTimestamp: number): number { + return Math.max(0, endExclusiveTimestamp - 1000); +} diff --git a/src/app/v1/_lib/codex/chat-completions-handler.ts b/src/app/v1/_lib/codex/chat-completions-handler.ts index 1b93880e1..184fc9dd8 100644 --- a/src/app/v1/_lib/codex/chat-completions-handler.ts +++ b/src/app/v1/_lib/codex/chat-completions-handler.ts @@ -10,13 +10,14 @@ import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { SessionTracker } from "@/lib/session-tracker"; -import { ProxyErrorHandler } from "../proxy/error-handler"; -import { ProxyError } from "../proxy/errors"; -import { ProxyForwarder } from "../proxy/forwarder"; -import { GuardPipelineBuilder, RequestType } from "../proxy/guard-pipeline"; -import { ProxyResponseHandler } from "../proxy/response-handler"; -import { ProxyResponses } from "../proxy/responses"; -import { ProxySession } from "../proxy/session"; +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { GuardPipelineBuilder, RequestType } from "@/app/v1/_lib/proxy/guard-pipeline"; +import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; +import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { ChatCompletionRequest } from "./types/compatible"; /** @@ -45,7 +46,7 @@ export async function handleChatCompletions(c: Context): Promise { const isResponseAPIFormat = "input" in request && Array.isArray(request.input); if (!isOpenAIFormat && !isResponseAPIFormat) { - return new Response( + const response = new Response( JSON.stringify({ error: { message: @@ -56,6 +57,7 @@ export async function handleChatCompletions(c: Context): Promise { }), { status: 400, headers: { "Content-Type": "application/json" } } ); + return await attachSessionIdToErrorResponse(session.sessionId, response); } if (isOpenAIFormat) { @@ -144,7 +146,7 @@ export async function handleChatCompletions(c: Context): Promise { // 验证必需字段 if (!request.model) { - return new Response( + const response = new Response( JSON.stringify({ error: { message: "Invalid request: model is required", @@ -154,6 +156,7 @@ export async function handleChatCompletions(c: Context): Promise { }), { status: 400, headers: { "Content-Type": "application/json" } } ); + return await attachSessionIdToErrorResponse(session.sessionId, response); } } @@ -161,7 +164,9 @@ export async function handleChatCompletions(c: Context): Promise { const pipeline = GuardPipelineBuilder.fromRequestType(type); const early = await pipeline.run(session); - if (early) return early; + if (early) { + return await attachSessionIdToErrorResponse(session.sessionId, early); + } // 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens if (session.sessionId && !session.isCountTokensRequest()) { @@ -187,7 +192,8 @@ export async function handleChatCompletions(c: Context): Promise { const response = await ProxyForwarder.send(session); // 5. 响应处理(自动转换回 OpenAI 格式) - return await ProxyResponseHandler.dispatch(session, response); + const handled = await ProxyResponseHandler.dispatch(session, response); + return await attachSessionIdToErrorResponse(session.sessionId, handled); } catch (error) { logger.error("[ChatCompletions] Handler error:", error); if (session) { diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 32f3242a9..ee38cbf41 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -9,6 +9,7 @@ import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder, RequestType } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; import { ProxyResponses } from "./proxy/responses"; +import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxySession } from "./proxy/session"; export async function handleProxyRequest(c: Context): Promise { @@ -54,7 +55,9 @@ export async function handleProxyRequest(c: Context): Promise { // Run guard chain; may return early Response const early = await pipeline.run(session); - if (early) return early; + if (early) { + return await attachSessionIdToErrorResponse(session.sessionId, early); + } // 9. 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens if (session.sessionId && !session.isCountTokensRequest()) { @@ -76,7 +79,8 @@ export async function handleProxyRequest(c: Context): Promise { } const response = await ProxyForwarder.send(session); - return await ProxyResponseHandler.dispatch(session, response); + const handled = await ProxyResponseHandler.dispatch(session, response); + return await attachSessionIdToErrorResponse(session.sessionId, handled); } catch (error) { logger.error("Proxy handler error:", error); if (session) { diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index c14e53c89..000ae7efc 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -14,6 +14,7 @@ import { ProxyError, type RateLimitError, } from "./errors"; +import { attachSessionIdToErrorResponse } from "./error-session-id"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; @@ -60,7 +61,7 @@ export class ProxyErrorHandler { rateLimitMetadata ); - return response; + return await attachSessionIdToErrorResponse(session.sessionId, response); } // 识别 ProxyError,提取详细信息(包含上游响应) @@ -132,21 +133,27 @@ export class ProxyErrorHandler { }); // 跳过响应体覆写,但仍可应用状态码覆写 if (override.statusCode !== null) { - return ProxyResponses.buildError( - responseStatusCode, + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + responseStatusCode, + clientErrorMessage, + undefined, + undefined, + safeRequestId + ) + ); + } + // 两者都无效,返回原始错误(但仍透传 request_id,因为有覆写意图) + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + statusCode, clientErrorMessage, undefined, undefined, safeRequestId - ); - } - // 两者都无效,返回原始错误(但仍透传 request_id,因为有覆写意图) - return ProxyResponses.buildError( - statusCode, - clientErrorMessage, - undefined, - undefined, - safeRequestId + ) ); } @@ -187,10 +194,13 @@ export class ProxyErrorHandler { overridden: true, }); - return new Response(JSON.stringify(responseBody), { - status: responseStatusCode, - headers: { "Content-Type": "application/json" }, - }); + return await attachSessionIdToErrorResponse( + session.sessionId, + new Response(JSON.stringify(responseBody), { + status: responseStatusCode, + headers: { "Content-Type": "application/json" }, + }) + ); } // 情况 2: 仅状态码覆写 - 返回客户端安全消息,但使用覆写的状态码 @@ -207,12 +217,15 @@ export class ProxyErrorHandler { overridden: true, }); - return ProxyResponses.buildError( - responseStatusCode, - clientErrorMessage, - undefined, - undefined, - safeRequestId + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + responseStatusCode, + clientErrorMessage, + undefined, + undefined, + safeRequestId + ) ); } } @@ -223,7 +236,10 @@ export class ProxyErrorHandler { overridden: false, }); - return ProxyResponses.buildError(statusCode, clientErrorMessage); + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError(statusCode, clientErrorMessage) + ); } /** diff --git a/src/app/v1/_lib/proxy/error-session-id.ts b/src/app/v1/_lib/proxy/error-session-id.ts new file mode 100644 index 000000000..eb4827b34 --- /dev/null +++ b/src/app/v1/_lib/proxy/error-session-id.ts @@ -0,0 +1,56 @@ +export function attachSessionIdToErrorMessage( + sessionId: string | null | undefined, + message: string +): string { + if (!sessionId) return message; + if (message.includes("cch_session_id:")) return message; + return `${message} (cch_session_id: ${sessionId})`; +} + +export async function attachSessionIdToErrorResponse( + sessionId: string | null | undefined, + response: Response +): Promise { + if (!sessionId) return response; + if (response.status < 400) return response; + + const headers = new Headers(response.headers); + headers.set("x-cch-session-id", sessionId); + + const contentType = headers.get("content-type") || ""; + if (contentType.includes("text/event-stream")) { + return new Response(response.body, { status: response.status, headers }); + } + + if (!contentType.includes("application/json")) { + return new Response(response.body, { status: response.status, headers }); + } + + let text: string; + try { + text = await response.clone().text(); + } catch { + return new Response(response.body, { status: response.status, headers }); + } + + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + parsed.error && + typeof parsed.error === "object" && + "message" in parsed.error && + typeof (parsed.error as { message?: unknown }).message === "string" + ) { + const p = parsed as { error: { message: string } } & Record; + p.error.message = attachSessionIdToErrorMessage(sessionId, p.error.message); + return new Response(JSON.stringify(p), { status: response.status, headers }); + } + } catch { + // best-effort: keep original response body + } + + return new Response(text, { status: response.status, headers }); +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index aecad1909..04bd542e8 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -347,6 +347,8 @@ export const messageRequest = pgTable('message_request', { messageRequestUserQueryIdx: index('idx_message_request_user_query').on(table.userId, table.createdAt).where(sql`${table.deletedAt} IS NULL`), // Session 查询索引(按 session 聚合查看对话) messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`), + // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) + messageRequestSessionIdPrefixIdx: index('idx_message_request_session_id_prefix').on(sql`${table.sessionId} varchar_pattern_ops`).where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // Session + Sequence 复合索引(用于 Session 内请求列表查询) messageRequestSessionSeqIdx: index('idx_message_request_session_seq').on(table.sessionId, table.requestSequence).where(sql`${table.deletedAt} IS NULL`), // Endpoint 过滤查询索引(仅针对未删除数据) diff --git a/src/lib/constants/usage-logs.constants.ts b/src/lib/constants/usage-logs.constants.ts new file mode 100644 index 000000000..da3b0baf6 --- /dev/null +++ b/src/lib/constants/usage-logs.constants.ts @@ -0,0 +1,3 @@ +export const SESSION_ID_SUGGESTION_MIN_LEN = 2; +export const SESSION_ID_SUGGESTION_MAX_LEN = 128; +export const SESSION_ID_SUGGESTION_LIMIT = 20; diff --git a/src/lib/utils/clipboard.ts b/src/lib/utils/clipboard.ts index 5a980bc12..cb65f905f 100644 --- a/src/lib/utils/clipboard.ts +++ b/src/lib/utils/clipboard.ts @@ -11,20 +11,48 @@ export function isClipboardSupported(): boolean { return window.isSecureContext && !!navigator.clipboard?.writeText; } +function tryCopyViaExecCommand(text: string): boolean { + if (typeof document === "undefined" || !document.body) return false; + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + textarea.select(); + + const ok = document.execCommand?.("copy") ?? false; + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + /** - * 尝试复制文本到剪贴板 + * 尝试复制文本到剪贴板(Clipboard API 优先,失败则走 execCommand fallback) * @returns 是否成功复制 */ -export async function copyToClipboard(text: string): Promise { - if (!isClipboardSupported()) { - return false; - } +export async function copyTextToClipboard(text: string): Promise { + if (typeof window === "undefined") return false; - try { - await navigator.clipboard.writeText(text); - return true; - } catch (err) { - console.error("复制失败:", err); - return false; + if (isClipboardSupported()) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch {} } + + return tryCopyViaExecCommand(text); +} + +/** + * 尝试复制文本到剪贴板 + * @returns 是否成功复制 + */ +export async function copyToClipboard(text: string): Promise { + return copyTextToClipboard(text); } diff --git a/src/repository/_shared/like.ts b/src/repository/_shared/like.ts new file mode 100644 index 000000000..ab40b2727 --- /dev/null +++ b/src/repository/_shared/like.ts @@ -0,0 +1,3 @@ +export function escapeLike(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 1098107ce..e64c1651e 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -6,12 +6,15 @@ import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/s import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; +import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; export interface UsageLogFilters { userId?: number; keyId?: number; providerId?: number; + /** Session ID(精确匹配;空字符串/空白视为不筛选) */ + sessionId?: string; /** 开始时间戳(毫秒),用于 >= 比较 */ startTime?: number; /** 结束时间戳(毫秒),用于 < 比较 */ @@ -115,6 +118,7 @@ export async function findUsageLogsBatch( userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -141,6 +145,11 @@ export async function findUsageLogsBatch( conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + if (startTime !== undefined) { const startDate = new Date(startTime); conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); @@ -320,6 +329,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -346,6 +356,11 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + // 使用毫秒时间戳进行时间比较 // 前端传递的是浏览器本地时区的毫秒时间戳,直接与数据库的 timestamptz 比较 // PostgreSQL 会自动处理时区转换 @@ -545,6 +560,64 @@ export async function getUsedEndpoints(): Promise { return results.map((r) => r.endpoint).filter((e): e is string => e !== null); } +export interface UsageLogSessionIdSuggestionFilters { + term: string; + userId?: number; + keyId?: number; + providerId?: number; + limit?: number; +} + +export async function findUsageLogSessionIdSuggestions( + filters: UsageLogSessionIdSuggestionFilters +): Promise { + const { term, userId, keyId, providerId } = filters; + const limit = Math.min(50, Math.max(1, filters.limit ?? 20)); + const trimmedTerm = term.trim(); + if (!trimmedTerm) return []; + + const pattern = `${escapeLike(trimmedTerm)}%`; + const conditions = [ + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + sql`${messageRequest.sessionId} IS NOT NULL`, + sql`length(${messageRequest.sessionId}) > 0`, + sql`${messageRequest.sessionId} LIKE ${pattern} ESCAPE '\\'`, + ]; + + if (userId !== undefined) { + conditions.push(eq(messageRequest.userId, userId)); + } + + if (keyId !== undefined) { + conditions.push(eq(keysTable.id, keyId)); + } + + if (providerId !== undefined) { + conditions.push(eq(messageRequest.providerId, providerId)); + } + + const baseQuery = db + .select({ + sessionId: messageRequest.sessionId, + firstSeen: sql`min(${messageRequest.createdAt})`, + }) + .from(messageRequest); + + const query = + keyId !== undefined + ? baseQuery.innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + : baseQuery; + + const results = await query + .where(and(...conditions)) + .groupBy(messageRequest.sessionId) + .orderBy(desc(sql`min(${messageRequest.createdAt})`)) + .limit(limit); + + return results.map((r) => r.sessionId).filter((id): id is string => Boolean(id)); +} + /** * 独立获取使用日志聚合统计(用于可折叠面板按需加载) * @@ -560,6 +633,7 @@ export async function findUsageLogsStats( userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -584,6 +658,11 @@ export async function findUsageLogsStats( conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + if (startTime !== undefined) { const startDate = new Date(startTime); conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); diff --git a/tests/unit/dashboard-logs-filters-time-range.test.tsx b/tests/unit/dashboard-logs-filters-time-range.test.tsx new file mode 100644 index 000000000..079ad3747 --- /dev/null +++ b/tests/unit/dashboard-logs-filters-time-range.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters"; +import dashboardMessages from "../../messages/en/dashboard.json"; + +vi.mock("@/app/[locale]/dashboard/logs/_components/logs-date-range-picker", () => ({ + LogsDateRangePicker: ({ + onDateRangeChange, + }: { + onDateRangeChange: (range: { startDate?: string; endDate?: string }) => void; + }) => ( + + ), +})); + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function actClick(el: Element | null) { + if (!el) throw new Error("element not found"); + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("UsageLogsFilters - seconds-level time range", () => { + test("defaults to full-day semantics (end is exclusive next-day 00:00:00)", async () => { + const onChange = vi.fn(); + + const { container, unmount } = renderWithIntl( + {}} + /> + ); + + await actClick(container.querySelector("[data-testid='mock-date-range']")); + + const timeInputs = Array.from(container.querySelectorAll("input[type='time']")); + expect(timeInputs).toHaveLength(2); + + const applyBtn = Array.from(container.querySelectorAll("button")).find( + (b) => (b.textContent || "").trim() === "Apply Filter" + ); + await actClick(applyBtn ?? null); + + const expectedStart = new Date(2026, 0, 1, 0, 0, 0, 0).getTime(); + const expectedEnd = new Date(2026, 0, 3, 0, 0, 0, 0).getTime(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ startTime: expectedStart, endTime: expectedEnd }) + ); + + unmount(); + }); + + test("Apply drops leaked page field from runtime filters object", async () => { + const onChange = vi.fn(); + + const leakedFilters = { sessionId: "abc", page: 3 } as unknown as Parameters< + typeof UsageLogsFilters + >[0]["filters"]; + + const { container, unmount } = renderWithIntl( + {}} + /> + ); + + const applyBtn = Array.from(container.querySelectorAll("button")).find( + (b) => (b.textContent || "").trim() === "Apply Filter" + ); + await actClick(applyBtn ?? null); + + expect(onChange).toHaveBeenCalledTimes(1); + const calledFilters = onChange.mock.calls[0]?.[0] as Record | undefined; + expect(calledFilters).toEqual(expect.objectContaining({ sessionId: "abc" })); + expect(calledFilters && "page" in calledFilters).toBe(false); + + unmount(); + }); +}); diff --git a/tests/unit/dashboard-logs-query-utils.test.ts b/tests/unit/dashboard-logs-query-utils.test.ts new file mode 100644 index 000000000..415dfe6b2 --- /dev/null +++ b/tests/unit/dashboard-logs-query-utils.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; +import { + buildLogsUrlQuery, + parseLogsUrlFilters, +} from "@/app/[locale]/dashboard/logs/_utils/logs-query"; + +describe("dashboard logs url query utils", () => { + test("parses and trims sessionId", () => { + const parsed = parseLogsUrlFilters({ sessionId: " abc " }); + expect(parsed.sessionId).toBe("abc"); + }); + + test("array params use the first value", () => { + const parsed = parseLogsUrlFilters({ + sessionId: [" abc ", "ignored"], + userId: ["1", "2"], + statusCode: ["!200", "200"], + }); + expect(parsed.sessionId).toBe("abc"); + expect(parsed.userId).toBe(1); + expect(parsed.excludeStatusCode200).toBe(true); + }); + + test("statusCode '!200' maps to excludeStatusCode200", () => { + const parsed = parseLogsUrlFilters({ statusCode: "!200" }); + expect(parsed.excludeStatusCode200).toBe(true); + expect(parsed.statusCode).toBeUndefined(); + }); + + test("parseIntParam returns undefined for invalid numbers", () => { + const parsed = parseLogsUrlFilters({ userId: "NaN", startTime: "bad" }); + expect(parsed.userId).toBeUndefined(); + expect(parsed.startTime).toBeUndefined(); + }); + + test("buildLogsUrlQuery omits empty sessionId", () => { + const query = buildLogsUrlQuery({ sessionId: " " }); + expect(query.get("sessionId")).toBeNull(); + }); + + test("buildLogsUrlQuery includes sessionId and time range", () => { + const query = buildLogsUrlQuery({ sessionId: "abc", startTime: 1, endTime: 2 }); + expect(query.get("sessionId")).toBe("abc"); + expect(query.get("startTime")).toBe("1"); + expect(query.get("endTime")).toBe("2"); + }); + + test("buildLogsUrlQuery includes startTime/endTime even when 0", () => { + const query = buildLogsUrlQuery({ startTime: 0, endTime: 0 }); + expect(query.get("startTime")).toBe("0"); + expect(query.get("endTime")).toBe("0"); + }); + + test("parseLogsUrlFilters sanitizes invalid page (<1) to undefined", () => { + expect(parseLogsUrlFilters({ page: "0" }).page).toBeUndefined(); + expect(parseLogsUrlFilters({ page: "-1" }).page).toBeUndefined(); + expect(parseLogsUrlFilters({ page: "1" }).page).toBe(1); + }); + + test("buildLogsUrlQuery only includes page when > 1", () => { + expect(buildLogsUrlQuery({ page: 0 }).get("page")).toBeNull(); + expect(buildLogsUrlQuery({ page: 1 }).get("page")).toBeNull(); + expect(buildLogsUrlQuery({ page: 2 }).get("page")).toBe("2"); + }); + + test("build + parse roundtrip preserves filters", () => { + const original = { + userId: 1, + keyId: 2, + providerId: 3, + sessionId: "abc", + startTime: 10, + endTime: 20, + statusCode: 500, + excludeStatusCode200: false, + model: "m", + endpoint: "/v1/messages", + minRetryCount: 2, + }; + const query = buildLogsUrlQuery(original); + + const parsed = parseLogsUrlFilters(Object.fromEntries(query.entries())); + expect(parsed).toEqual(expect.objectContaining(original)); + }); + + test("buildLogsUrlQuery includes minRetryCount even when 0", () => { + const query = buildLogsUrlQuery({ minRetryCount: 0 }); + expect(query.get("minRetry")).toBe("0"); + }); +}); diff --git a/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx new file mode 100644 index 000000000..0bc1d5dce --- /dev/null +++ b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx @@ -0,0 +1,322 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, test, vi } from "vitest"; +import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: toastMocks, +})); + +const usageLogsActionMocks = vi.hoisted(() => ({ + exportUsageLogs: vi.fn(async () => ({ ok: true, data: "" })), + getUsageLogSessionIdSuggestions: vi.fn(async () => ({ ok: true, data: ["session_1"] })), + getModelList: vi.fn(async () => ({ ok: true, data: [] })), + getStatusCodeList: vi.fn(async () => ({ ok: true, data: [] })), + getEndpointList: vi.fn(async () => ({ ok: true, data: [] })), +})); + +const usersActionMocks = vi.hoisted(() => ({ + searchUsersForFilter: vi.fn(async () => ({ + ok: true, + data: [] as Array<{ id: number; name: string }>, + })), +})); + +vi.mock("@/actions/usage-logs", () => ({ + exportUsageLogs: usageLogsActionMocks.exportUsageLogs, + getUsageLogSessionIdSuggestions: usageLogsActionMocks.getUsageLogSessionIdSuggestions, + getModelList: usageLogsActionMocks.getModelList, + getStatusCodeList: usageLogsActionMocks.getStatusCodeList, + getEndpointList: usageLogsActionMocks.getEndpointList, +})); + +vi.mock("@/actions/users", () => ({ + searchUsersForFilter: usersActionMocks.searchUsersForFilter, +})); + +vi.mock("@/components/ui/popover", async () => { + const React = await import("react"); + + type PopoverCtx = { open: boolean; onOpenChange?: (open: boolean) => void }; + const PopoverContext = React.createContext({ open: false }); + + function Popover({ + open, + onOpenChange, + children, + }: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: ReactNode; + }) { + return ( + + {children} + + ); + } + + function PopoverTrigger({ asChild, children }: { asChild?: boolean; children?: ReactNode }) { + const { open, onOpenChange } = React.useContext(PopoverContext); + const child = React.Children.only(children) as unknown as { + props: { onClick?: (e: unknown) => void }; + }; + + const handleClick = (e: unknown) => { + child.props.onClick?.(e); + onOpenChange?.(!open); + }; + + if (asChild) { + return React.cloneElement(child as never, { onClick: handleClick }); + } + + return ( + + ); + } + + function PopoverContent({ children }: { children?: ReactNode }) { + const { open } = React.useContext(PopoverContext); + if (!open) return null; + return
{children}
; + } + + function PopoverAnchor({ children }: { children?: ReactNode }) { + return <>{children}; + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + }; +}); + +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children?: ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function actClick(el: Element | null) { + if (!el) throw new Error("element not found"); + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function setReactInputValue(input: HTMLInputElement, value: string) { + const prototype = Object.getPrototypeOf(input) as HTMLInputElement; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("UsageLogsFilters sessionId suggestions", () => { + test("should debounce and require min length (>=2)", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + setReactInputValue(input!, "a"); + }); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).not.toHaveBeenCalled(); + + await act(async () => { + setReactInputValue(input!, "ab"); + }); + + await act(async () => { + vi.advanceTimersByTime(299); + }); + await flushMicrotasks(); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ term: "ab" }) + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + test("should keep input focused when opening suggestions popover", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + input?.focus(); + }); + await flushMicrotasks(); + + expect(document.activeElement).toBe(input); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + test("should reload suggestions when provider scope changes (term unchanged)", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + await flushMicrotasks(); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + input?.focus(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + + const providerBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent || "").includes("logs.filters.allProviders") + ); + await actClick(providerBtn ?? null); + await flushMicrotasks(); + + const providerItem = Array.from(document.querySelectorAll("[cmdk-item]")).find((el) => + (el.textContent || "").includes("p1") + ); + await actClick(providerItem ?? null); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(2); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenLastCalledWith( + expect.objectContaining({ term: "ab", providerId: 1 }) + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); +}); diff --git a/tests/unit/dashboard-logs-time-range-utils.test.ts b/tests/unit/dashboard-logs-time-range-utils.test.ts new file mode 100644 index 000000000..db14488ce --- /dev/null +++ b/tests/unit/dashboard-logs-time-range-utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { + dateStringWithClockToTimestamp, + formatClockFromTimestamp, + inclusiveEndTimestampFromExclusive, + parseClockString, +} from "@/app/[locale]/dashboard/logs/_utils/time-range"; + +describe("dashboard logs time range utils", () => { + test("parseClockString supports HH:MM and defaults seconds to 0", () => { + expect(parseClockString("01:02")).toEqual({ hours: 1, minutes: 2, seconds: 0 }); + }); + + test("parseClockString falls back to 0 for invalid numbers", () => { + expect(parseClockString("xx:yy:zz")).toEqual({ hours: 0, minutes: 0, seconds: 0 }); + expect(parseClockString("01:02:xx")).toEqual({ hours: 1, minutes: 2, seconds: 0 }); + }); + + test("dateStringWithClockToTimestamp combines local date + clock", () => { + const ts = dateStringWithClockToTimestamp("2026-01-01", "01:02:03"); + const expected = new Date(2026, 0, 1, 1, 2, 3, 0).getTime(); + expect(ts).toBe(expected); + }); + + test("dateStringWithClockToTimestamp returns undefined for invalid date", () => { + expect(dateStringWithClockToTimestamp("not-a-date", "01:02:03")).toBeUndefined(); + expect(dateStringWithClockToTimestamp("2026-13-40", "01:02:03")).toBeUndefined(); + }); + + test("exclusive end time round-trips to inclusive end time (+/-1s)", () => { + const inclusive = dateStringWithClockToTimestamp("2026-01-02", "04:05:06"); + expect(inclusive).toBeDefined(); + const exclusive = inclusive! + 1000; + expect(inclusiveEndTimestampFromExclusive(exclusive)).toBe(inclusive); + }); + + test("inclusiveEndTimestampFromExclusive clamps at 0", () => { + expect(inclusiveEndTimestampFromExclusive(0)).toBe(0); + expect(inclusiveEndTimestampFromExclusive(500)).toBe(0); + }); + + test("formatClockFromTimestamp uses HH:MM:SS", () => { + const ts = new Date(2026, 0, 1, 1, 2, 3, 0).getTime(); + expect(formatClockFromTimestamp(ts)).toBe("01:02:03"); + }); +}); diff --git a/tests/unit/lib/constants/usage-logs.constants.test.ts b/tests/unit/lib/constants/usage-logs.constants.test.ts new file mode 100644 index 000000000..3876d61aa --- /dev/null +++ b/tests/unit/lib/constants/usage-logs.constants.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { + SESSION_ID_SUGGESTION_LIMIT, + SESSION_ID_SUGGESTION_MAX_LEN, + SESSION_ID_SUGGESTION_MIN_LEN, +} from "@/lib/constants/usage-logs.constants"; + +describe("Usage logs constants", () => { + test("SESSION_ID_SUGGESTION_* 常量保持稳定(避免前后端阈值漂移)", () => { + expect(SESSION_ID_SUGGESTION_MIN_LEN).toBe(2); + expect(SESSION_ID_SUGGESTION_MAX_LEN).toBe(128); + expect(SESSION_ID_SUGGESTION_LIMIT).toBe(20); + }); +}); diff --git a/tests/unit/lib/utils/clipboard.test.ts b/tests/unit/lib/utils/clipboard.test.ts new file mode 100644 index 000000000..443710f91 --- /dev/null +++ b/tests/unit/lib/utils/clipboard.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { copyTextToClipboard, copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; + +function stubSecureContext(value: boolean) { + Object.defineProperty(window, "isSecureContext", { + value, + configurable: true, + }); +} + +function stubClipboard(writeText: (text: string) => Promise | void) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +} + +function stubExecCommand(impl: (command: string) => boolean) { + Object.defineProperty(document, "execCommand", { + value: impl, + configurable: true, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("clipboard utils", () => { + test("SSR 环境:isClipboardSupported/copyTextToClipboard 应返回 false", async () => { + vi.stubGlobal("window", undefined as unknown as Window); + + expect(isClipboardSupported()).toBe(false); + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("isClipboardSupported: 仅在安全上下文且 Clipboard API 可用时为 true", () => { + stubSecureContext(false); + stubClipboard(vi.fn()); + expect(isClipboardSupported()).toBe(false); + + stubSecureContext(true); + stubClipboard(vi.fn()); + expect(isClipboardSupported()).toBe(true); + }); + + test("copyTextToClipboard: Clipboard API 成功时返回 true", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard(writeText); + + const execCommand = vi.fn(); + stubExecCommand(execCommand); + + const before = document.querySelectorAll("textarea").length; + await expect(copyTextToClipboard("abc")).resolves.toBe(true); + const after = document.querySelectorAll("textarea").length; + + expect(writeText).toHaveBeenCalledWith("abc"); + expect(execCommand).not.toHaveBeenCalled(); + expect(after).toBe(before); + }); + + test("copyTextToClipboard: Clipboard API 失败时应 fallback 到 execCommand", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockRejectedValue(new Error("fail")); + stubClipboard(writeText); + + const execCommand = vi.fn(() => true); + stubExecCommand(execCommand); + + const before = document.querySelectorAll("textarea").length; + await expect(copyTextToClipboard("abc")).resolves.toBe(true); + const after = document.querySelectorAll("textarea").length; + + expect(writeText).toHaveBeenCalledWith("abc"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(after).toBe(before); + }); + + test("copyTextToClipboard: 无 Clipboard API 时走 fallback(execCommand 失败则返回 false)", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + + stubExecCommand(() => false); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("copyTextToClipboard: fallback 抛错时返回 false", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + + stubExecCommand(() => { + throw new Error("boom"); + }); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("copyToClipboard: 兼容旧 API(内部调用 copyTextToClipboard)", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard(writeText); + + await expect(copyToClipboard("abc")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("abc"); + }); + + test("copyTextToClipboard: 无 document 时 fallback 直接返回 false", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.stubGlobal("document", undefined as unknown as Document); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); +}); diff --git a/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts b/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts index acc1cd310..fb69e9c46 100644 --- a/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts +++ b/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts @@ -232,6 +232,28 @@ beforeEach(() => { }); describe("handleChatCompletions:必须走 GuardPipeline", () => { + test("pipeline 早退错误时,应附带 x-cch-session-id 且 message 追加 cch_session_id", async () => { + h.session = createSession({ + model: "gpt-4.1-mini", + messages: [{ role: "user", content: "hi" }], + }); + h.session.sessionId = "s_123"; + h.clientGuardResult = new Response( + JSON.stringify({ + error: { message: "client blocked", type: "invalid_request_error", code: "client_blocked" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + + const { handleChatCompletions } = await import("@/app/v1/_lib/codex/chat-completions-handler"); + const res = await handleChatCompletions({} as any); + + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + const body = await res.json(); + expect(body.error.message).toBe("client blocked (cch_session_id: s_123)"); + }); + test("请求体既不是 messages 也不是 input 时,应返回 400(不进入 pipeline)", async () => { h.session = createSession({}); diff --git a/tests/unit/proxy/error-handler-session-id-error.test.ts b/tests/unit/proxy/error-handler-session-id-error.test.ts new file mode 100644 index 000000000..15942e10d --- /dev/null +++ b/tests/unit/proxy/error-handler-session-id-error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; + +describe("ProxyErrorHandler.handle - session id on errors", () => { + test("decorates error response with x-cch-session-id and message suffix", async () => { + const session = { + sessionId: "s_123", + messageContext: null, + startTime: Date.now(), + getProviderChain: () => [], + getCurrentModel: () => null, + getContext1mApplied: () => false, + provider: null, + } as any; + + const res = await ProxyErrorHandler.handle(session, new Error("boom")); + + expect(res.status).toBe(500); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("boom (cch_session_id: s_123)"); + }); +}); diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts new file mode 100644 index 000000000..d336e0dc4 --- /dev/null +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test, vi } from "vitest"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; + +const h = vi.hoisted(() => ({ + session: { + originalFormat: "openai", + sessionId: "s_123", + requestUrl: new URL("http://localhost/v1/messages"), + request: { + model: "gpt", + message: {}, + }, + isCountTokensRequest: () => false, + setOriginalFormat: () => {}, + messageContext: null, + provider: null, + } as any, + + fromContextError: null as unknown, + pipelineError: null as unknown, + earlyResponse: null as Response | null, + forwardResponse: new Response("ok", { status: 200 }), + dispatchedResponse: null as Response | null, + + endpointFormat: null as string | null, + trackerCalls: [] as string[], +})); + +vi.mock("@/app/v1/_lib/proxy/session", () => ({ + ProxySession: { + fromContext: async () => { + if (h.fromContextError) throw h.fromContextError; + return h.session; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ + RequestType: { CHAT: "CHAT", COUNT_TOKENS: "COUNT_TOKENS" }, + GuardPipelineBuilder: { + fromRequestType: () => ({ + run: async () => { + if (h.pipelineError) throw h.pipelineError; + return h.earlyResponse; + }, + }), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/format-mapper", () => ({ + detectClientFormat: () => "openai", + detectFormatByEndpoint: () => h.endpointFormat, +})); + +vi.mock("@/app/v1/_lib/proxy/forwarder", () => ({ + ProxyForwarder: { + send: async () => h.forwardResponse, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/response-handler", () => ({ + ProxyResponseHandler: { + dispatch: async () => h.dispatchedResponse ?? h.forwardResponse, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/error-handler", () => ({ + ProxyErrorHandler: { + handle: async () => new Response("handled", { status: 502 }), + }, +})); + +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + incrementConcurrentCount: async () => { + h.trackerCalls.push("inc"); + }, + decrementConcurrentCount: async () => { + h.trackerCalls.push("dec"); + }, + }, +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + startRequest: () => { + h.trackerCalls.push("startRequest"); + }, + endRequest: () => {}, + }), + }, +})); + +describe("handleProxyRequest - session id on errors", async () => { + const { handleProxyRequest } = await import("@/app/v1/_lib/proxy-handler"); + + test("decorates early error response with x-cch-session-id and message suffix", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = ProxyResponses.buildError(400, "bad request"); + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("decorates dispatch error response with x-cch-session-id and message suffix", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("upstream", { status: 502 }); + h.dispatchedResponse = ProxyResponses.buildError(502, "bad gateway"); + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(502); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("bad gateway (cch_session_id: s_123)"); + }); + + test("covers claude format detection branch without breaking behavior", async () => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = ProxyResponses.buildError(400, "bad request"); + h.session.requestUrl = new URL("http://localhost/v1/unknown"); + h.session.request = { model: "gpt", message: { contents: [] } }; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + }); + + test("covers endpoint format detection + tracking + finally decrement", async () => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = "openai"; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("ok", { status: 200 }); + h.dispatchedResponse = null; + + h.session.sessionId = "s_123"; + h.session.messageContext = { id: 1, user: { id: 1, name: "u" }, key: { name: "k" } }; + h.session.provider = { id: 1, name: "p" }; + h.session.isCountTokensRequest = () => false; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(200); + expect(h.trackerCalls).toEqual(["inc", "startRequest", "dec"]); + }); + + test("session not created and ProxyError thrown: returns buildError without session header", async () => { + h.fromContextError = new ProxyError("upstream", 401); + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(401); + expect(res.headers.get("x-cch-session-id")).toBeNull(); + const body = await res.json(); + expect(body.error.message).toBe("upstream"); + }); + + test("session created but pipeline throws: routes to ProxyErrorHandler.handle", async () => { + h.fromContextError = null; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = new Error("pipeline boom"); + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(502); + expect(await res.text()).toBe("handled"); + }); + + test("session not created and non-ProxyError thrown: returns 500 buildError", async () => { + h.fromContextError = new Error("boom"); + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toBe("代理请求发生未知错误"); + }); +}); diff --git a/tests/unit/proxy/responses-session-id.test.ts b/tests/unit/proxy/responses-session-id.test.ts new file mode 100644 index 000000000..6b2ffbdb3 --- /dev/null +++ b/tests/unit/proxy/responses-session-id.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; + +describe("ProxyResponses.attachSessionIdToErrorResponse", () => { + test("adds x-cch-session-id and appends to error.message for JSON error responses", async () => { + const response = ProxyResponses.buildError(400, "bad request"); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.status).toBe(400); + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await decorated.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("does nothing when sessionId is missing", async () => { + const response = ProxyResponses.buildError(400, "bad request"); + const decorated = await attachSessionIdToErrorResponse(undefined, response); + + expect(decorated).toBe(response); + }); + + test("does nothing for non-error responses", async () => { + const response = new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated).toBe(response); + }); + + test("does not double-append when message already contains cch_session_id", async () => { + const response = ProxyResponses.buildError(400, "bad request (cch_session_id: s_123)"); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + const body = await decorated.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("adds header for non-json error responses (body unchanged)", async () => { + const response = new Response("oops", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.status).toBe(500); + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.text()).toBe("oops"); + }); + + test("adds header for json without error.message (body unchanged)", async () => { + const response = new Response(JSON.stringify({ foo: "bar" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.json()).toEqual({ foo: "bar" }); + }); + + test("adds header for SSE error responses (no body rewrite)", async () => { + const response = new Response("data: hi\n\n", { + status: 500, + headers: { "Content-Type": "text/event-stream" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.text()).toBe("data: hi\n\n"); + }); +}); diff --git a/tests/unit/repository/escape-like.test.ts b/tests/unit/repository/escape-like.test.ts new file mode 100644 index 000000000..c243fa188 --- /dev/null +++ b/tests/unit/repository/escape-like.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { escapeLike } from "@/repository/_shared/like"; + +describe("escapeLike", () => { + test("普通字符串保持不变", () => { + expect(escapeLike("abc-123")).toBe("abc-123"); + }); + + test("%/_/\\\\ 应被转义(用于 LIKE ... ESCAPE '\\\\')", () => { + expect(escapeLike("%")).toBe("\\%"); + expect(escapeLike("_")).toBe("\\_"); + expect(escapeLike("\\")).toBe("\\\\"); + }); + + test("组合输入应按字面量匹配语义转义", () => { + expect(escapeLike("a%b_c\\d")).toBe("a\\%b\\_c\\\\d"); + }); + + test("空字符串应返回空字符串", () => { + expect(escapeLike("")).toBe(""); + }); +}); diff --git a/tests/unit/repository/usage-logs-sessionid-filter.test.ts b/tests/unit/repository/usage-logs-sessionid-filter.test.ts new file mode 100644 index 000000000..d87d1991f --- /dev/null +++ b/tests/unit/repository/usage-logs-sessionid-filter.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + whereArgs?.push(arg); + return query; + }); + + return query; +} + +describe("Usage logs sessionId filter", () => { + test("findUsageLogsBatch: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], whereArgs)); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + await findUsageLogsBatch({}); + await findUsageLogsBatch({ sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsBatch: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], whereArgs)); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + await findUsageLogsBatch({ sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); + + test("findUsageLogsWithDetails: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsWithDetails } = await import("@/repository/usage-logs"); + await findUsageLogsWithDetails({ page: 1, pageSize: 1 }); + await findUsageLogsWithDetails({ page: 1, pageSize: 1, sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsWithDetails: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsWithDetails } = await import("@/repository/usage-logs"); + await findUsageLogsWithDetails({ page: 1, pageSize: 1, sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); + + test("findUsageLogsStats: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + await findUsageLogsStats({}); + await findUsageLogsStats({ sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsStats: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + await findUsageLogsStats({ sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); +}); diff --git a/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts b/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts new file mode 100644 index 000000000..0114be6cc --- /dev/null +++ b/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery( + result: T, + opts?: { + whereArgs?: unknown[]; + groupByArgs?: unknown[]; + orderByArgs?: unknown[]; + limitArgs?: unknown[]; + } +) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + opts?.whereArgs?.push(arg); + return query; + }); + query.groupBy = vi.fn((...args: unknown[]) => { + opts?.groupByArgs?.push(args); + return query; + }); + query.orderBy = vi.fn((...args: unknown[]) => { + opts?.orderByArgs?.push(args); + return query; + }); + query.limit = vi.fn((arg: unknown) => { + opts?.limitArgs?.push(arg); + return query; + }); + + return query; +} + +describe("Usage logs sessionId suggestions", () => { + test("term 为空/空白:应直接返回空数组且不查询 DB", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + const result = await findUsageLogSessionIdSuggestions({ term: " " }); + + expect(result).toEqual([]); + expect(selectMock).not.toHaveBeenCalled(); + }); + + test("term 应 trim 并按 MIN(created_at) 倒序,limit 生效", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const groupByArgs: unknown[] = []; + const orderByArgs: unknown[] = []; + const limitArgs: unknown[] = []; + const selectMock = vi.fn(() => + createThenableQuery( + [ + { sessionId: "session_1", firstSeen: new Date("2026-01-01T00:00:00Z") }, + { sessionId: null, firstSeen: new Date("2026-01-01T00:00:00Z") }, + ], + { whereArgs, groupByArgs, orderByArgs, limitArgs } + ) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + const result = await findUsageLogSessionIdSuggestions({ + term: " abc ", + userId: 1, + keyId: 2, + providerId: 3, + limit: 20, + }); + + expect(result).toEqual(["session_1"]); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("like"); + expect(whereSql).toContain("escape"); + expect(whereSql).toContain("abc%"); + expect(whereSql).not.toContain("%abc%"); + expect(whereSql).not.toContain("ilike"); + expect(whereSql).not.toContain(" abc "); + + expect(groupByArgs.length).toBeGreaterThan(0); + + expect(orderByArgs.length).toBeGreaterThan(0); + const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); + expect(orderSql).toContain("min"); + + expect(limitArgs).toEqual([20]); + }); + + test("term 含 %/_/\\\\:应按字面量前缀匹配(需转义)", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], { whereArgs })); + + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ + term: "a%_\\b", + limit: 20, + }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("like"); + expect(whereSql).toContain("escape"); + expect(whereSql).toContain("a\\%\\_\\\\b%"); + expect(whereSql).not.toContain("ilike"); + }); + + test("limit 应被 clamp 到 [1, 50]", async () => { + vi.resetModules(); + + const limitArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], { limitArgs })); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", limit: 500 }); + + expect(limitArgs).toEqual([50]); + }); + + test("keyId 未提供时不应 innerJoin(keysTable)", async () => { + vi.resetModules(); + + const query = createThenableQuery([]); + const selectMock = vi.fn(() => query); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", limit: 20 }); + + expect(query.innerJoin).not.toHaveBeenCalled(); + }); + + test("keyId 提供时才 innerJoin(keysTable)", async () => { + vi.resetModules(); + + const query = createThenableQuery([]); + const selectMock = vi.fn(() => query); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", keyId: 2, limit: 20 }); + + expect(query.innerJoin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.include-session-id-in-errors.config.ts b/vitest.include-session-id-in-errors.config.ts new file mode 100644 index 000000000..e07479f8b --- /dev/null +++ b/vitest.include-session-id-in-errors.config.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Include CCH session id in client errors - scoped coverage config + * + * 目的: + * - 验证错误响应中附带 sessionId 的行为(message + header) + * - 覆盖率只统计本次改动相关模块,避免引入 Next/DB/Redis 重模块 + * - 覆盖率阈值:>= 90% + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/proxy/responses-session-id.test.ts", + "tests/unit/proxy/proxy-handler-session-id-error.test.ts", + "tests/unit/proxy/error-handler-session-id-error.test.ts", + "tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json"], + reportsDirectory: "./coverage-include-session-id-in-errors", + + include: [ + "src/app/v1/_lib/proxy/error-session-id.ts", + "src/app/v1/_lib/proxy-handler.ts", + "src/app/v1/_lib/codex/chat-completions-handler.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); diff --git a/vitest.logs-sessionid-time-filter.config.ts b/vitest.logs-sessionid-time-filter.config.ts new file mode 100644 index 000000000..7e2cef58c --- /dev/null +++ b/vitest.logs-sessionid-time-filter.config.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Dashboard Logs(Session ID + 秒级时间筛选)专项覆盖率配置 + * + * 目的: + * - 仅统计本需求可纯函数化/可隔离模块的覆盖率(>= 90%) + * - 仍然执行关键回归相关的单测集合,避免只跑“指标好看”的子集 + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/repository/usage-logs-sessionid-filter.test.ts", + "tests/unit/repository/usage-logs-sessionid-suggestions.test.ts", + "tests/unit/dashboard-logs-query-utils.test.ts", + "tests/unit/dashboard-logs-time-range-utils.test.ts", + "tests/unit/dashboard-logs-filters-time-range.test.tsx", + "tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx", + "tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx", + "src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json", "lcov"], + reportsDirectory: "./coverage-logs-sessionid-time-filter", + + include: [ + "src/app/[locale]/dashboard/logs/_utils/logs-query.ts", + "src/app/[locale]/dashboard/logs/_utils/time-range.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); diff --git a/vitest.usage-logs-sessionid-search.config.ts b/vitest.usage-logs-sessionid-search.config.ts new file mode 100644 index 000000000..b83fa0368 --- /dev/null +++ b/vitest.usage-logs-sessionid-search.config.ts @@ -0,0 +1,60 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Dashboard Logs(Session ID 搜索:前缀匹配 + LIKE 转义)专项覆盖率配置 + * + * 目的: + * - 仅统计本需求可隔离模块的覆盖率(>= 90%) + * - 同时执行关联单测集合,避免只跑“指标好看”的子集 + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/repository/usage-logs-sessionid-suggestions.test.ts", + "tests/unit/repository/usage-logs-sessionid-filter.test.ts", + "tests/unit/repository/warmup-stats-exclusion.test.ts", + "tests/unit/repository/escape-like.test.ts", + "tests/unit/lib/constants/usage-logs.constants.test.ts", + "tests/unit/lib/utils/clipboard.test.ts", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json", "lcov"], + reportsDirectory: "./coverage-usage-logs-sessionid-search", + + include: [ + "src/repository/_shared/like.ts", + "src/lib/constants/usage-logs.constants.ts", + "src/lib/utils/clipboard.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); From 2d6dcb20f62589b250a9d05b9e489fc16ad2b343 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:51:48 +0800 Subject: [PATCH 23/24] feat(dashboard/logs): add fullscreen mode (#632) --- messages/en/dashboard.json | 8 +- messages/ja/dashboard.json | 8 +- messages/ru/dashboard.json | 8 +- messages/zh-CN/dashboard.json | 8 +- messages/zh-TW/dashboard.json | 8 +- .../usage-logs-view-virtualized.tsx | 377 ++++++++++++++---- .../virtualized-logs-table.test.tsx | 20 + .../_components/virtualized-logs-table.tsx | 232 ++++++----- .../v1/_lib/codex/chat-completions-handler.ts | 8 +- src/app/v1/_lib/proxy-handler.ts | 2 +- src/app/v1/_lib/proxy/error-handler.ts | 2 +- src/hooks/use-fullscreen.ts | 61 +++ ...hboard-logs-fullscreen-overlay-ui.test.tsx | 188 +++++++++ 13 files changed, 726 insertions(+), 204 deletions(-) create mode 100644 src/hooks/use-fullscreen.ts create mode 100644 tests/unit/dashboard-logs-fullscreen-overlay-ui.test.tsx diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index fed1a3327..34d5fc29b 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -142,14 +142,18 @@ "loadedCount": "Loaded {count} records", "loadingMore": "Loading more...", "noMoreData": "All records loaded", - "scrollToTop": "Back to top" + "scrollToTop": "Back to top", + "hideProviderColumn": "Hide Provider Column", + "showProviderColumn": "Show Provider Column" }, "actions": { "refresh": "Refresh", "refreshing": "Refreshing...", "stopAutoRefresh": "Stop Auto-Refresh", "startAutoRefresh": "Start Auto-Refresh", - "view": "View" + "view": "View", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit Fullscreen" }, "error": { "loadFailed": "Load Failed", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 57af4b2e4..c25c08880 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -142,14 +142,18 @@ "loadedCount": "{count} 件のレコードを読み込みました", "loadingMore": "読み込み中...", "noMoreData": "すべてのレコードを読み込みました", - "scrollToTop": "トップへ戻る" + "scrollToTop": "トップへ戻る", + "hideProviderColumn": "プロバイダー列を非表示", + "showProviderColumn": "プロバイダー列を表示" }, "actions": { "refresh": "更新", "refreshing": "更新中...", "stopAutoRefresh": "自動更新を停止", "startAutoRefresh": "自動更新を開始", - "view": "表示" + "view": "表示", + "fullscreen": "全画面表示", + "exitFullscreen": "全画面を終了" }, "error": { "loadFailed": "読み込み失敗", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a7ad1418d..7a1caeec4 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -142,14 +142,18 @@ "loadedCount": "Загружено {count} записей", "loadingMore": "Загрузка...", "noMoreData": "Все записи загружены", - "scrollToTop": "Наверх" + "scrollToTop": "Наверх", + "hideProviderColumn": "Скрыть столбец провайдера", + "showProviderColumn": "Показать столбец провайдера" }, "actions": { "refresh": "Обновить", "refreshing": "Обновление...", "stopAutoRefresh": "Остановить автообновление", "startAutoRefresh": "Начать автообновление", - "view": "Просмотр" + "view": "Просмотр", + "fullscreen": "Полный экран", + "exitFullscreen": "Выйти из полного экрана" }, "error": { "loadFailed": "Ошибка загрузки", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 9627bf0c6..ac6723668 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -142,14 +142,18 @@ "loadedCount": "已加载 {count} 条记录", "loadingMore": "加载更多中...", "noMoreData": "已加载全部记录", - "scrollToTop": "回到顶部" + "scrollToTop": "回到顶部", + "hideProviderColumn": "隐藏供应商列", + "showProviderColumn": "显示供应商列" }, "actions": { "refresh": "刷新", "refreshing": "刷新中...", "stopAutoRefresh": "停止自动刷新", "startAutoRefresh": "开启自动刷新", - "view": "查看" + "view": "查看", + "fullscreen": "全屏显示", + "exitFullscreen": "退出全屏" }, "error": { "loadFailed": "加载失败", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 887447252..f451f5884 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -142,14 +142,18 @@ "loadedCount": "已載入 {count} 筆記錄", "loadingMore": "載入更多中...", "noMoreData": "已載入全部記錄", - "scrollToTop": "回到頂端" + "scrollToTop": "回到頂端", + "hideProviderColumn": "隱藏供應商欄", + "showProviderColumn": "顯示供應商欄" }, "actions": { "refresh": "重新整理", "refreshing": "重新整理中...", "stopAutoRefresh": "停止自動重新整理", "startAutoRefresh": "啟用自動重新整理", - "view": "檢視" + "view": "檢視", + "fullscreen": "全螢幕顯示", + "exitFullscreen": "退出全螢幕" }, "error": { "loadFailed": "載入失敗", diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 2e8d19cab..45d4e3834 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -1,15 +1,21 @@ "use client"; import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Pause, Play, RefreshCw } from "lucide-react"; +import { Expand, Minimize2, Pause, Play, RefreshCw } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { getKeys } from "@/actions/keys"; +import type { OverviewData } from "@/actions/overview"; +import { getOverviewData } from "@/actions/overview"; import { getProviders } from "@/actions/providers"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { useFullscreen } from "@/hooks/use-fullscreen"; import type { CurrencyCode } from "@/lib/utils/currency"; +import { formatCurrency } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource, SystemSettings } from "@/types/system-config"; @@ -18,7 +24,6 @@ import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table"; -// Create a stable QueryClient instance const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -46,6 +51,14 @@ async function fetchSystemSettings(): Promise { return response.json() as Promise; } +async function fetchOverviewData(): Promise { + const result = await getOverviewData(); + if (!result.ok) { + throw new Error(result.error || "FETCH_OVERVIEW_FAILED"); + } + return result.data; +} + function UsageLogsViewContent({ isAdmin, userId, @@ -56,6 +69,8 @@ function UsageLogsViewContent({ billingModelSource = "original", }: UsageLogsViewVirtualizedProps) { const t = useTranslations("dashboard"); + const tc = useTranslations("customs"); + const locale = useLocale(); const router = useRouter(); const _params = useSearchParams(); const queryClientInstance = useQueryClient(); @@ -64,11 +79,52 @@ function UsageLogsViewContent({ const refreshTimeoutRef = useRef | null>(null); const paramsKey = _params.toString(); + const fullscreen = useFullscreen(); + const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); + const [hideProviderColumn, setHideProviderColumn] = useState(false); + const wasInFullscreenRef = useRef(false); + + const resetFullscreenState = useCallback(() => { + setIsFullscreenOpen(false); + setHideProviderColumn(false); + wasInFullscreenRef.current = false; + }, []); + + const msFormatter = useMemo( + () => + new Intl.NumberFormat(locale, { + style: "unit", + unit: "millisecond", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }), + [locale] + ); + + const secFormatter = useMemo( + () => + new Intl.NumberFormat(locale, { + style: "unit", + unit: "second", + unitDisplay: "narrow", + maximumFractionDigits: 1, + }), + [locale] + ); + + const formatResponseTime = useCallback( + (ms: number) => { + if (ms < 1000) return msFormatter.format(ms); + return secFormatter.format(ms / 1000); + }, + [msFormatter, secFormatter] + ); + const shouldFetchSettings = !currencyCode || !billingModelSource; const { data: systemSettings } = useQuery({ queryKey: ["system-settings"], queryFn: fetchSystemSettings, - enabled: shouldFetchSettings, + enabled: shouldFetchSettings || isFullscreenOpen, }); const resolvedCurrencyCode = currencyCode ?? systemSettings?.currencyDisplay ?? "USD"; @@ -91,7 +147,6 @@ function UsageLogsViewContent({ const resolvedProviders = providers ?? providersData; const resolvedKeys = initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : []); - // Parse filters from URL with stable reference const filters = useMemo(() => { return parseLogsUrlFilters({ userId: searchParams.userId, @@ -120,7 +175,14 @@ function UsageLogsViewContent({ searchParams.page, ]); - // Manual refresh handler + const { data: overviewData } = useQuery({ + queryKey: ["overview-data"], + queryFn: fetchOverviewData, + enabled: isFullscreenOpen, + refetchInterval: isFullscreenOpen ? 3000 : false, + refetchOnWindowFocus: false, + }); + const handleManualRefresh = useCallback(async () => { setIsManualRefreshing(true); await queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] }); @@ -130,13 +192,57 @@ function UsageLogsViewContent({ refreshTimeoutRef.current = setTimeout(() => setIsManualRefreshing(false), 500); }, [queryClientInstance]); - // Handle filter changes + const handleEnterFullscreen = useCallback(async () => { + if (!fullscreen.supported) return; + + wasInFullscreenRef.current = false; + + try { + await fullscreen.request(document.documentElement); + setIsFullscreenOpen(true); + } catch (error) { + console.error("[UsageLogsViewVirtualized] Failed to enter fullscreen", error); + toast.error(t("logs.error.loadFailed")); + } + }, [fullscreen, t]); + + const handleExitFullscreen = useCallback(async () => { + resetFullscreenState(); + await fullscreen.exit(); + }, [fullscreen, resetFullscreenState]); + + useEffect(() => { + if (!isFullscreenOpen) return; + + if (fullscreen.isFullscreen) { + wasInFullscreenRef.current = true; + return; + } + + if (wasInFullscreenRef.current) { + resetFullscreenState(); + } + }, [fullscreen.isFullscreen, isFullscreenOpen, resetFullscreenState]); + + useEffect(() => { + if (!isFullscreenOpen) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key === "Escape") { + void handleExitFullscreen(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleExitFullscreen, isFullscreenOpen]); + const handleFilterChange = (newFilters: Omit) => { const query = buildLogsUrlQuery(newFilters); router.push(`/dashboard/logs?${query.toString()}`); }; - // Invalidate query when URL changes (e.g., browser back/forward navigation) useEffect(() => { void paramsKey; queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] }); @@ -151,89 +257,194 @@ function UsageLogsViewContent({ }, []); return ( -
- {/* Collapsible stats panel */} - - - {/* Filters */} - - - {t("title.filterCriteria")} - - - router.push("/dashboard/logs")} - isProvidersLoading={isProvidersLoading} - isKeysLoading={isKeysLoading} - /> - - - - {/* Data table with virtual scrolling */} - - -
- {t("title.usageLogs")} -
- {/* Manual refresh button */} - + <> +
+ + + + + {t("title.filterCriteria")} + + + router.push("/dashboard/logs")} + isProvidersLoading={isProvidersLoading} + isKeysLoading={isKeysLoading} + /> + + + + + +
+ {t("title.usageLogs")} +
+ - {/* Auto refresh toggle */} + + + +
+
+
+ + + +
+
+ + {isFullscreenOpen ? ( +
+
+
+
+ {systemSettings?.siteTitle ?? t("title.usageLogs")} +
+
+ +
+ +
+
+
+ {tc("metrics.concurrent")} +
+
+ {overviewData?.concurrentSessions ?? 0} +
+
+
+
+ {tc("metrics.todayRequests")} +
+
+ {overviewData?.todayRequests ?? 0} +
+
+
+
+ {tc("metrics.todayCost")} +
+
+ {formatCurrency(overviewData?.todayCost ?? 0, resolvedCurrencyCode, 2)} +
+
+
+
+ {tc("metrics.avgResponse")} +
+
+ {formatResponseTime(overviewData?.avgResponseTime ?? 0)} +
+
+
- - - - - -
+ +
+
+
+
+
{t("logs.table.hideProviderColumn")}
+ +
+
+
+ +
+ +
+
+ ) : null} + ); } diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index 342b3d971..fe0e31751 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -256,6 +256,26 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).toContain("animate-spin"); }); + test("hides provider column when hiddenColumns includes provider", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, providerName: "provider" })]; + + const htmlWithProvider = renderToStaticMarkup( + + ); + expect(htmlWithProvider).toContain("logs.columns.provider"); + + const htmlHidden = renderToStaticMarkup( + + ); + expect(htmlHidden).not.toContain("logs.columns.provider"); + }); + test("renders provider summary and fetching state when enabled", () => { mockIsLoading = false; mockIsError = false; diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 6989abb0f..b1ab7813c 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -43,12 +43,18 @@ export interface VirtualizedLogsTableFilters { minRetryCount?: number; } +type VirtualizedLogsTableColumn = "provider"; + interface VirtualizedLogsTableProps { filters: VirtualizedLogsTableFilters; currencyCode?: CurrencyCode; billingModelSource?: BillingModelSource; autoRefreshEnabled?: boolean; autoRefreshIntervalMs?: number; + hideStatusBar?: boolean; + hideScrollToTop?: boolean; + hiddenColumns?: VirtualizedLogsTableColumn[]; + bodyClassName?: string; } export function VirtualizedLogsTable({ @@ -57,12 +63,18 @@ export function VirtualizedLogsTable({ billingModelSource = "original", autoRefreshEnabled = true, autoRefreshIntervalMs = 5000, + hideStatusBar = false, + hideScrollToTop = false, + hiddenColumns, + bodyClassName, }: VirtualizedLogsTableProps) { const t = useTranslations("dashboard"); const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); + const hideProviderColumn = hiddenColumns?.includes("provider") ?? false; + // Dialog state for model redirect click const [dialogState, setDialogState] = useState<{ logId: number | null; @@ -138,7 +150,7 @@ export function VirtualizedLogsTable({ }, []); // Reset scroll when filters change - // biome-ignore lint/correctness/useExhaustiveDependencies: filters is intentionally used to trigger scroll reset on filter change + // biome-ignore lint/correctness/useExhaustiveDependencies: `filters` is an intentional trigger useEffect(() => { if (parentRef.current) { parentRef.current.scrollTop = 0; @@ -169,16 +181,18 @@ export function VirtualizedLogsTable({ return (
{/* Status bar */} -
- {t("logs.table.loadedCount", { count: allLogs.length })} - {isFetchingNextPage && ( - - - {t("logs.table.loadingMore")} - - )} - {!hasNextPage && allLogs.length > 0 && {t("logs.table.noMoreData")}} -
+ {hideStatusBar ? null : ( +
+ {t("logs.table.loadedCount", { count: allLogs.length })} + {isFetchingNextPage && ( + + + {t("logs.table.loadingMore")} + + )} + {!hasNextPage && allLogs.length > 0 && {t("logs.table.noMoreData")}} +
+ )} {/* Table with virtual scrolling */}
@@ -200,12 +214,14 @@ export function VirtualizedLogsTable({ > {t("logs.columns.sessionId")}
-
- {t("logs.columns.provider")} -
+ {hideProviderColumn ? null : ( +
+ {t("logs.columns.provider")} +
+ )}
{t("logs.columns.model")}
@@ -240,7 +256,11 @@ export function VirtualizedLogsTable({
{/* Virtualized body */} -
+
{/* Provider */} -
- {log.blockedBy ? ( - - - {t("logs.table.blocked")} - - ) : ( -
-
- {(() => { - // 计算倍率,用于判断是否显示 Badge - const successfulProvider = - log.providerChain && log.providerChain.length > 0 - ? [...log.providerChain] - .reverse() - .find( - (item) => - item.reason === "request_success" || - item.reason === "retry_success" - ) - : null; - const actualCostMultiplier = - successfulProvider?.costMultiplier ?? log.costMultiplier; - const multiplier = Number(actualCostMultiplier); - const hasCostBadge = - actualCostMultiplier !== "" && - actualCostMultiplier != null && - Number.isFinite(multiplier) && - multiplier !== 1; - - return ( - <> -
- 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || - log.providerName || - tChain("circuit.unknown") - } - hasCostBadge={hasCostBadge} - /> -
- {/* Cost multiplier badge */} - {hasCostBadge && ( - 1 - ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" - : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" - } + {hideProviderColumn ? null : ( +
+ {log.blockedBy ? ( + + + {t("logs.table.blocked")} + + ) : ( +
+
+ {(() => { + // 计算倍率,用于判断是否显示 Badge + const successfulProvider = + log.providerChain && log.providerChain.length > 0 + ? [...log.providerChain] + .reverse() + .find( + (item) => + item.reason === "request_success" || + item.reason === "retry_success" + ) + : null; + const actualCostMultiplier = + successfulProvider?.costMultiplier ?? log.costMultiplier; + const multiplier = Number(actualCostMultiplier); + const hasCostBadge = + actualCostMultiplier !== "" && + actualCostMultiplier != null && + Number.isFinite(multiplier) && + multiplier !== 1; + + return ( + <> +
+ 0 + ? log.providerChain[log.providerChain.length - 1].name + : null) || + log.providerName || + tChain("circuit.unknown") + } + hasCostBadge={hasCostBadge} + /> +
+ {/* Cost multiplier badge */} + {hasCostBadge && ( + 1 + ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" + : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" + } + > + x{multiplier.toFixed(2)} + + )} + + ); + })()} +
+ {log.providerChain && + log.providerChain.length > 0 && + formatProviderSummary(log.providerChain, tChain) && ( + + + + + {formatProviderSummary(log.providerChain, tChain)} + + + - x{multiplier.toFixed(2)} - - )} - - ); - })()} +

+ {formatProviderSummary(log.providerChain, tChain)} +

+
+
+
+ )}
- {log.providerChain && - log.providerChain.length > 0 && - formatProviderSummary(log.providerChain, tChain) && ( - - - - - {formatProviderSummary(log.providerChain, tChain)} - - - -

- {formatProviderSummary(log.providerChain, tChain)} -

-
-
-
- )} -
- )} -
+ )} +
+ )} {/* Model */}
@@ -661,7 +683,7 @@ export function VirtualizedLogsTable({
{/* Scroll to top button */} - {showScrollToTop && ( + {hideScrollToTop ? null : showScrollToTop ? ( - )} + ) : null}
); } diff --git a/src/app/v1/_lib/codex/chat-completions-handler.ts b/src/app/v1/_lib/codex/chat-completions-handler.ts index 184fc9dd8..1aa32aa07 100644 --- a/src/app/v1/_lib/codex/chat-completions-handler.ts +++ b/src/app/v1/_lib/codex/chat-completions-handler.ts @@ -7,17 +7,17 @@ */ import type { Context } from "hono"; -import { logger } from "@/lib/logger"; -import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; -import { SessionTracker } from "@/lib/session-tracker"; import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; +import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; import { ProxyError } from "@/app/v1/_lib/proxy/errors"; import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; import { GuardPipelineBuilder, RequestType } from "@/app/v1/_lib/proxy/guard-pipeline"; -import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { logger } from "@/lib/logger"; +import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; +import { SessionTracker } from "@/lib/session-tracker"; import type { ChatCompletionRequest } from "./types/compatible"; /** diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index ee38cbf41..5257ef670 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -3,13 +3,13 @@ import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { SessionTracker } from "@/lib/session-tracker"; import { ProxyErrorHandler } from "./proxy/error-handler"; +import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxyError } from "./proxy/errors"; import { detectClientFormat, detectFormatByEndpoint } from "./proxy/format-mapper"; import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder, RequestType } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; import { ProxyResponses } from "./proxy/responses"; -import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxySession } from "./proxy/session"; export async function handleProxyRequest(c: Context): Promise { diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index 000ae7efc..b511b9824 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -7,6 +7,7 @@ import { import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message"; +import { attachSessionIdToErrorResponse } from "./error-session-id"; import { getErrorOverrideAsync, isEmptyResponseError, @@ -14,7 +15,6 @@ import { ProxyError, type RateLimitError, } from "./errors"; -import { attachSessionIdToErrorResponse } from "./error-session-id"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; diff --git a/src/hooks/use-fullscreen.ts b/src/hooks/use-fullscreen.ts new file mode 100644 index 000000000..60414c485 --- /dev/null +++ b/src/hooks/use-fullscreen.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +export interface FullscreenController { + supported: boolean; + isFullscreen: boolean; + request: (element: HTMLElement | null) => Promise; + exit: () => Promise; +} + +function getFullscreenElement(): Element | null { + return document.fullscreenElement ?? null; +} + +export function useFullscreen(): FullscreenController { + const supported = useMemo(() => { + if (typeof document === "undefined") return false; + return typeof document.exitFullscreen === "function"; + }, []); + + const [isFullscreen, setIsFullscreen] = useState(() => { + if (typeof document === "undefined") return false; + return Boolean(getFullscreenElement()); + }); + + useEffect(() => { + if (typeof document === "undefined") return; + + const handleChange = () => { + setIsFullscreen(Boolean(getFullscreenElement())); + }; + + document.addEventListener("fullscreenchange", handleChange); + return () => { + document.removeEventListener("fullscreenchange", handleChange); + }; + }, []); + + const request = useCallback(async (element: HTMLElement | null) => { + if (!element) return; + if (typeof element.requestFullscreen !== "function") return; + + await element.requestFullscreen(); + }, []); + + const exit = useCallback(async () => { + if (typeof document === "undefined") return; + if (typeof document.exitFullscreen !== "function") return; + if (!getFullscreenElement()) return; + + await document.exitFullscreen(); + }, []); + + return { + supported, + isFullscreen, + request, + exit, + }; +} diff --git a/tests/unit/dashboard-logs-fullscreen-overlay-ui.test.tsx b/tests/unit/dashboard-logs-fullscreen-overlay-ui.test.tsx new file mode 100644 index 000000000..aec07ec51 --- /dev/null +++ b/tests/unit/dashboard-logs-fullscreen-overlay-ui.test.tsx @@ -0,0 +1,188 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, test, vi, beforeEach } from "vitest"; + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + error: toastMocks.error, + }, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => ({ toString: () => "" }), +})); + +const invalidateQueriesMock = vi.fn(); +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useQuery: () => ({ data: undefined, isLoading: false }), + useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }), + }; +}); + +const fullscreenMocks = vi.hoisted(() => ({ + request: vi.fn(async () => {}), + exit: vi.fn(async () => {}), +})); + +vi.mock("@/hooks/use-fullscreen", () => ({ + useFullscreen: () => ({ + supported: true, + isFullscreen: false, + request: fullscreenMocks.request, + exit: fullscreenMocks.exit, + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, onCheckedChange, ...props }: any) => ( +