diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 0a5955259..23fbedb1f 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1385,6 +1385,9 @@ "keyStatus": { "enabled": "Enabled", "disabled": "Disabled", + "active": "Active", + "expired": "Expired", + "expiringSoon": "Expiring Soon", "keyEnabled": "Key enabled", "keyDisabled": "Key disabled", "toggleKeyStatus": "Toggle key status", @@ -1396,6 +1399,9 @@ "userStatus": { "enabled": "Enabled", "disabled": "Disabled", + "active": "Active", + "expired": "Expired", + "expiringSoon": "Expiring Soon", "userEnabled": "User enabled", "userDisabled": "User disabled", "toggleUserStatus": "Toggle user status", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 1e5863b68..b7a46d054 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1344,11 +1344,24 @@ }, "keyStatus": { "enabled": "有効", - "disabled": "無効" + "disabled": "無効", + "active": "正常", + "expired": "期限切れ", + "expiringSoon": "まもなく期限切れ", + "keyEnabled": "キーが有効になりました", + "keyDisabled": "キーが無効になりました", + "toggleKeyStatus": "キー状態を切り替える", + "clickToDisableKey": "クリックしてキーを無効化", + "clickToEnableKey": "クリックしてキーを有効化", + "operationFailed": "操作に失敗しました", + "clickToQuickRenew": "クリックして更新" }, "userStatus": { "enabled": "有効", "disabled": "無効", + "active": "正常", + "expired": "期限切れ", + "expiringSoon": "まもなく期限切れ", "userEnabled": "ユーザーが有効になりました", "userDisabled": "ユーザーが無効になりました", "toggleUserStatus": "ユーザー状態を切り替える", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 04f40b578..e66ae1a38 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1357,11 +1357,24 @@ }, "keyStatus": { "enabled": "Включён", - "disabled": "Отключён" + "disabled": "Отключён", + "active": "Активен", + "expired": "Истёк", + "expiringSoon": "Скоро истечёт", + "keyEnabled": "Ключ включён", + "keyDisabled": "Ключ отключён", + "toggleKeyStatus": "Переключить статус ключа", + "clickToDisableKey": "Нажмите, чтобы отключить ключ", + "clickToEnableKey": "Нажмите, чтобы включить ключ", + "operationFailed": "Операция не удалась", + "clickToQuickRenew": "Нажмите для быстрого продления" }, "userStatus": { "enabled": "Включён", "disabled": "Отключён", + "active": "Активен", + "expired": "Истёк", + "expiringSoon": "Скоро истечёт", "userEnabled": "Пользователь включён", "userDisabled": "Пользователь отключён", "toggleUserStatus": "Переключить статус пользователя", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 199d27f6a..dce1512ac 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1384,6 +1384,9 @@ "keyStatus": { "enabled": "启用", "disabled": "禁用", + "active": "正常", + "expired": "已过期", + "expiringSoon": "即将过期", "keyEnabled": "密钥已启用", "keyDisabled": "密钥已禁用", "toggleKeyStatus": "切换密钥启用状态", @@ -1395,6 +1398,9 @@ "userStatus": { "enabled": "启用", "disabled": "禁用", + "active": "正常", + "expired": "已过期", + "expiringSoon": "即将过期", "userEnabled": "用户已启用", "userDisabled": "用户已禁用", "toggleUserStatus": "切换用户启用状态", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c0d3de757..8ae5cd69d 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1356,11 +1356,24 @@ }, "keyStatus": { "enabled": "啟用", - "disabled": "停用" + "disabled": "停用", + "active": "正常", + "expired": "已過期", + "expiringSoon": "即將過期", + "keyEnabled": "密鑰已啟用", + "keyDisabled": "密鑰已停用", + "toggleKeyStatus": "切換密鑰狀態", + "clickToDisableKey": "點擊停用密鑰", + "clickToEnableKey": "點擊啟用密鑰", + "operationFailed": "操作失敗", + "clickToQuickRenew": "點擊快速續期" }, "userStatus": { "enabled": "啟用", "disabled": "停用", + "active": "正常", + "expired": "已過期", + "expiringSoon": "即將過期", "userEnabled": "使用者已啟用", "userDisabled": "使用者已停用", "toggleUserStatus": "切換使用者狀態", 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 24a438877..e2ed9e18b 100644 --- a/src/app/[locale]/dashboard/_components/user/key-row-item.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-row-item.tsx @@ -92,6 +92,8 @@ export interface KeyRowItemProps { }; } +const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72小时 + function splitGroups(value?: string | null): string[] { return (value ?? "") .split(",") @@ -99,6 +101,32 @@ function splitGroups(value?: string | null): string[] { .filter(Boolean); } +function formatExpiry(expiresAt: string | null | undefined, locale: string): string { + if (!expiresAt) return "-"; + const date = new Date(expiresAt); + // 如果解析失败(如"永不过期"等翻译文本),直接返回原文本 + if (Number.isNaN(date.getTime())) return expiresAt; + return formatDate(date, "yyyy-MM-dd", locale); +} + +function getKeyExpiryStatus( + status: "enabled" | "disabled", + expiresAt: string | null | undefined +): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } { + if (status === "disabled") return { label: "disabled", variant: "secondary" }; + if (!expiresAt) return { label: "active", variant: "default" }; + + const date = new Date(expiresAt); + if (Number.isNaN(date.getTime())) return { label: "active", variant: "default" }; + + const now = Date.now(); + const expTs = date.getTime(); + + if (expTs <= now) return { label: "expired", variant: "destructive" }; + if (expTs - now <= EXPIRING_SOON_MS) return { label: "expiringSoon", variant: "outline" }; + return { label: "active", variant: "default" }; +} + export function KeyRowItem({ keyData, userProviderGroup: _userProviderGroup, @@ -148,6 +176,9 @@ export function KeyRowItem({ const keyGroups = splitGroups(keyData.providerGroup); const effectiveGroups = keyGroups.length > 0 ? keyGroups : [translations.defaultGroup]; const visibleGroups = effectiveGroups.slice(0, 1); + + // 计算 key 过期状态 + const keyExpiryStatus = getKeyExpiryStatus(localStatus, localExpiresAt); const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length); const effectiveGroupText = effectiveGroups.join(", "); @@ -194,13 +225,6 @@ export function KeyRowItem({ } }; - const formatExpiry = (expiresAt: string | null | undefined): string => { - if (!expiresAt) return "-"; - const date = new Date(expiresAt); - if (Number.isNaN(date.getTime())) return "-"; - return formatDate(date, "yyyy-MM-dd", locale); - }; - const handleQuickRenewConfirm = async ( _keyId: number, expiresAt: Date, @@ -297,11 +321,8 @@ export function KeyRowItem({
{keyData.name}
- - {localStatus === "enabled" ? translations.status.enabled : translations.status.disabled} + + {tKeyStatus(keyExpiryStatus.label)}
@@ -444,7 +465,7 @@ export function KeyRowItem({ setQuickRenewOpen(true); }} > - {formatExpiry(localExpiresAt)} + {formatExpiry(localExpiresAt, locale)} {/* 操作 */} 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 fb4e3423f..722e642b6 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 @@ -13,6 +13,7 @@ import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useRouter } from "@/i18n/routing"; import { cn } from "@/lib/utils"; +import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; import { formatDate } from "@/lib/utils/date-format"; import type { UserDisplay } from "@/types/user"; import { KeyRowItem } from "./key-row-item"; @@ -66,6 +67,30 @@ export interface UserKeyTableRowProps { } const DEFAULT_GRID_COLUMNS_CLASS = "grid-cols-[minmax(260px,1fr)_120px_repeat(6,90px)_80px]"; +const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72小时 +const MAX_VISIBLE_GROUPS = 2; // 最多显示的分组数量 + +function splitGroups(value?: string | null): string[] { + return (value ?? "") + .split(",") + .map((g) => g.trim()) + .filter(Boolean); +} + +function getExpiryStatus( + isEnabled: boolean, + expiresAt: Date | null | undefined +): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } { + const now = Date.now(); + const expTs = expiresAt?.getTime(); + const hasExpiry = typeof expTs === "number" && Number.isFinite(expTs); + + if (!isEnabled) return { label: "disabled", variant: "secondary" }; + if (hasExpiry && expTs <= now) return { label: "expired", variant: "destructive" }; + if (hasExpiry && expTs - now <= EXPIRING_SOON_MS) + return { label: "expiringSoon", variant: "outline" }; + return { label: "active", variant: "default" }; +} function normalizeLimitValue(value: unknown): number | null { const raw = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; @@ -129,6 +154,14 @@ export function UserKeyTableRow({ const expiresText = formatExpiry(localExpiresAt ?? null, locale); + // 计算用户过期状态 + const expiryStatus = getExpiryStatus(localIsEnabled, localExpiresAt ?? null); + + // 处理 Provider Group:拆分成数组 + const userGroups = splitGroups(user.providerGroup); + const visibleGroups = userGroups.slice(0, MAX_VISIBLE_GROUPS); + const remainingGroupsCount = Math.max(0, userGroups.length - MAX_VISIBLE_GROUPS); + const limit5h = normalizeLimitValue(user.limit5hUsd); const limitDaily = normalizeLimitValue(user.dailyQuota); const limitWeekly = normalizeLimitValue(user.limitWeeklyUsd); @@ -222,11 +255,34 @@ export function UserKeyTableRow({ {isExpanded ? translations.collapse : translations.expand} {user.name} - {!localIsEnabled && ( + + {tUserStatus(expiryStatus.label)} + + {visibleGroups.map((group) => { + const bgColor = getGroupColor(group); + return ( + + {group} + + ); + })} + {remainingGroupsCount > 0 && ( - {translations.userStatus?.disabled || "Disabled"} + +{remainingGroupsCount} )} + {user.tags && user.tags.length > 0 && ( + + [{user.tags.join(", ")}] + + )} {user.note ? ( {user.note} ) : null} diff --git a/src/app/v1/_lib/proxy/auth-guard.ts b/src/app/v1/_lib/proxy/auth-guard.ts index dd5ebf28c..4f0645a03 100644 --- a/src/app/v1/_lib/proxy/auth-guard.ts +++ b/src/app/v1/_lib/proxy/auth-guard.ts @@ -25,7 +25,8 @@ export class ProxyAuthenticator { return null; } - return ProxyResponses.buildError(401, "令牌已过期或验证不正确"); + // 返回详细的错误信息,帮助用户快速定位问题 + return authState.errorResponse ?? ProxyResponses.buildError(401, "认证失败"); } private static async validate(headers: { @@ -52,7 +53,17 @@ export class ProxyAuthenticator { hasGeminiApiKeyHeader: !!headers.geminiApiKeyHeader, hasGeminiApiKeyQuery: !!headers.geminiApiKeyQuery, }); - return { user: null, key: null, apiKey: null, success: false }; + return { + user: null, + key: null, + apiKey: null, + success: false, + errorResponse: ProxyResponses.buildError( + 401, + "未提供认证凭据。请在 Authorization 头部、x-api-key 头部或 x-goog-api-key 头部中包含 API 密钥。", + "authentication_error" + ), + }; } const [firstKey] = providedKeys; @@ -62,7 +73,17 @@ export class ProxyAuthenticator { logger.warn("[ProxyAuthenticator] Multiple conflicting API keys provided", { keyCount: providedKeys.length, }); - return { user: null, key: null, apiKey: null, success: false }; + return { + user: null, + key: null, + apiKey: null, + success: false, + errorResponse: ProxyResponses.buildError( + 401, + "提供了多个冲突的 API 密钥。请仅使用一种认证方式。", + "authentication_error" + ), + }; } const apiKey = firstKey; @@ -74,7 +95,17 @@ export class ProxyAuthenticator { fromHeader: !!headers.authHeader || !!headers.apiKeyHeader || !!headers.geminiApiKeyHeader, fromQuery: !!headers.geminiApiKeyQuery, }); - return { user: null, key: null, apiKey, success: false }; + return { + user: null, + key: null, + apiKey, + success: false, + errorResponse: ProxyResponses.buildError( + 401, + "API 密钥无效。提供的密钥不存在或已被删除。", + "invalid_api_key" + ), + }; } // Check user status and expiration @@ -86,7 +117,17 @@ export class ProxyAuthenticator { userId: user.id, userName: user.name, }); - return { user: null, key: null, apiKey, success: false }; + return { + user: null, + key: null, + apiKey, + success: false, + errorResponse: ProxyResponses.buildError( + 401, + "用户账户已被禁用。请联系管理员。", + "user_disabled" + ), + }; } // 2. Check if user is expired (lazy expiration check) @@ -103,7 +144,17 @@ export class ProxyAuthenticator { error: error instanceof Error ? error.message : String(error), }); }); - return { user: null, key: null, apiKey, success: false }; + return { + user: null, + key: null, + apiKey, + success: false, + errorResponse: ProxyResponses.buildError( + 401, + `用户账户已于 ${user.expiresAt.toISOString().split("T")[0]} 过期。请续费订阅。`, + "user_expired" + ), + }; } logger.debug("[ProxyAuthenticator] Authentication successful", { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index e4d69d910..b4349047a 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -17,6 +17,7 @@ export interface AuthState { key: Key | null; apiKey: string | null; success: boolean; + errorResponse?: Response; // 认证失败时的详细错误响应 } export interface MessageContext {