diff --git a/messages/en/quota.json b/messages/en/quota.json index 3d4e4cc40..124ab271c 100644 --- a/messages/en/quota.json +++ b/messages/en/quota.json @@ -153,6 +153,9 @@ "label": "Monthly Cost", "resetAt": "Resets at" }, + "costTotal": { + "label": "Total Cost" + }, "concurrentSessions": { "label": "Concurrent Sessions" }, @@ -183,6 +186,7 @@ "costDaily": "Daily Quota", "costWeekly": "Weekly Quota", "costMonthly": "Monthly Quota", + "costTotal": "Total Quota", "concurrentSessions": "Concurrent Limit", "status": "Status", "actions": "Actions" @@ -235,6 +239,11 @@ "placeholder": "Unlimited", "current": "Current usage: {currency}{current} / {currency}{limit}" }, + "limitTotalUsd": { + "label": "Total Quota (USD)", + "placeholder": "Unlimited", + "current": "Current usage: {currency}{current} / {currency}{limit}" + }, "concurrentSessions": { "label": "Concurrent Session Quota", "placeholder": "0 = Unlimited", diff --git a/messages/ja/quota.json b/messages/ja/quota.json index 2a09ce1e8..bb6c51510 100644 --- a/messages/ja/quota.json +++ b/messages/ja/quota.json @@ -130,6 +130,9 @@ "label": "月次コスト", "resetAt": "リセット時刻" }, + "costTotal": { + "label": "総コスト" + }, "concurrentSessions": { "label": "同時セッション" }, @@ -160,6 +163,7 @@ "costDaily": "日次クォータ", "costWeekly": "週次クォータ", "costMonthly": "月次クォータ", + "costTotal": "総クォータ", "concurrentSessions": "同時制限", "status": "ステータス", "actions": "アクション" @@ -212,6 +216,11 @@ "placeholder": "無制限", "current": "現在使用: {currency}{current} / {currency}{limit}" }, + "limitTotalUsd": { + "label": "総クォータ (USD)", + "placeholder": "無制限", + "current": "現在使用: {currency}{current} / {currency}{limit}" + }, "concurrentSessions": { "label": "同時セッションクォータ", "placeholder": "0 = 無制限", diff --git a/messages/ru/quota.json b/messages/ru/quota.json index 6b23d09c7..0ebe777cc 100644 --- a/messages/ru/quota.json +++ b/messages/ru/quota.json @@ -153,6 +153,9 @@ "label": "Ежемесячные расходы", "resetAt": "Сброс в" }, + "costTotal": { + "label": "Общие расходы" + }, "concurrentSessions": { "label": "Параллельные сессии" }, @@ -183,6 +186,7 @@ "costDaily": "Дневная квота", "costWeekly": "Еженедельная квота", "costMonthly": "Ежемесячная квота", + "costTotal": "Общая квота", "concurrentSessions": "Лимит параллельных", "status": "Статус", "actions": "Действия" @@ -235,6 +239,11 @@ "placeholder": "Неограниченно", "current": "Использовано: {currency}{current} из {currency}{limit}" }, + "limitTotalUsd": { + "label": "Общая квота (USD)", + "placeholder": "Неограниченно", + "current": "Использовано: {currency}{current} из {currency}{limit}" + }, "concurrentSessions": { "label": "Квота параллельных сессий", "placeholder": "0 = без ограничений", diff --git a/messages/zh-CN/quota.json b/messages/zh-CN/quota.json index 52227fa68..63918d285 100644 --- a/messages/zh-CN/quota.json +++ b/messages/zh-CN/quota.json @@ -153,6 +153,9 @@ "label": "月消费", "resetAt": "重置于" }, + "costTotal": { + "label": "总消费" + }, "concurrentSessions": { "label": "并发 Session" }, @@ -183,6 +186,7 @@ "costDaily": "每日限额", "costWeekly": "周限额", "costMonthly": "月限额", + "costTotal": "总限额", "concurrentSessions": "并发限制", "status": "状态", "actions": "操作" @@ -235,6 +239,11 @@ "placeholder": "不限制", "current": "当前已用: {currency}{current} / {currency}{limit}" }, + "limitTotalUsd": { + "label": "总限额(USD)", + "placeholder": "不限制", + "current": "当前已用: {currency}{current} / {currency}{limit}" + }, "concurrentSessions": { "label": "并发 Session 限额", "placeholder": "0 = 不限制", diff --git a/messages/zh-TW/quota.json b/messages/zh-TW/quota.json index acac4bc86..bfa7a99fc 100644 --- a/messages/zh-TW/quota.json +++ b/messages/zh-TW/quota.json @@ -128,6 +128,9 @@ "label": "月消費", "resetAt": "重置於" }, + "costTotal": { + "label": "總消費" + }, "concurrentSessions": { "label": "並發 Session" }, @@ -158,6 +161,7 @@ "costDaily": "每日限額", "costWeekly": "周限額", "costMonthly": "月限額", + "costTotal": "總限額", "concurrentSessions": "並發限制", "status": "狀態", "actions": "操作" @@ -210,6 +214,11 @@ "placeholder": "不限制", "current": "當前已用: {currency}{current} / {currency}{limit}" }, + "limitTotalUsd": { + "label": "總限額 (USD)", + "placeholder": "不限制", + "current": "當前已用: {currency}{current} / {currency}{limit}" + }, "concurrentSessions": { "label": "並發 Session 限額", "placeholder": "0 = 不限制", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 918a694a8..5e81736b0 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -2690,6 +2690,7 @@ export async function getProviderLimitUsage(providerId: number): Promise< costDaily: { current: number; limit: number | null; resetAt?: Date }; costWeekly: { current: number; limit: number | null; resetAt: Date }; costMonthly: { current: number; limit: number | null; resetAt: Date }; + limitTotalUsd: { current: number; limit: number | null }; concurrentSessions: { current: number; limit: number }; }> > { @@ -2713,7 +2714,9 @@ export async function getProviderLimitUsage(providerId: number): Promise< getTimeRangeForPeriodWithMode, } = await import("@/lib/rate-limit/time-utils"); const { RateLimitService } = await import("@/lib/rate-limit"); - const { sumProviderCostInTimeRange } = await import("@/repository/statistics"); + const { sumProviderCostInTimeRange, sumProviderTotalCost } = await import( + "@/repository/statistics" + ); const limit5hResetMode = provider.limit5hResetMode ?? "rolling"; // 计算各周期的时间范围 @@ -2732,15 +2735,23 @@ export async function getProviderLimitUsage(providerId: number): Promise< ]); // 获取金额消费(直接查询数据库,确保配额显示与 DB 一致) - const [cost5h, costDaily, costWeekly, costMonthly, concurrentSessions] = await Promise.all([ - limit5hResetMode === "fixed" - ? RateLimitService.getCurrentCost(providerId, "provider", "5h", undefined, limit5hResetMode) - : sumProviderCostInTimeRange(providerId, range5h.startTime, range5h.endTime), - sumProviderCostInTimeRange(providerId, rangeDaily.startTime, rangeDaily.endTime), - sumProviderCostInTimeRange(providerId, rangeWeekly.startTime, rangeWeekly.endTime), - sumProviderCostInTimeRange(providerId, rangeMonthly.startTime, rangeMonthly.endTime), - SessionTracker.getProviderSessionCount(providerId), - ]); + const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = + await Promise.all([ + limit5hResetMode === "fixed" + ? RateLimitService.getCurrentCost( + providerId, + "provider", + "5h", + undefined, + limit5hResetMode + ) + : sumProviderCostInTimeRange(providerId, range5h.startTime, range5h.endTime), + sumProviderCostInTimeRange(providerId, rangeDaily.startTime, rangeDaily.endTime), + sumProviderCostInTimeRange(providerId, rangeWeekly.startTime, rangeWeekly.endTime), + sumProviderCostInTimeRange(providerId, rangeMonthly.startTime, rangeMonthly.endTime), + sumProviderTotalCost(providerId), + SessionTracker.getProviderSessionCount(providerId), + ]); // 获取重置时间信息 const resetDaily = await getResetInfoWithMode( @@ -2779,6 +2790,10 @@ export async function getProviderLimitUsage(providerId: number): Promise< limit: provider.limitMonthlyUsd, resetAt: resetMonthly.resetAt!, }, + limitTotalUsd: { + current: totalCost, + limit: provider.limitTotalUsd ?? null, + }, concurrentSessions: { current: concurrentSessions, limit: provider.limitConcurrentSessions || 0, @@ -2800,6 +2815,7 @@ export type ProviderLimitUsageData = { costDaily: { current: number; limit: number | null; resetAt?: Date }; costWeekly: { current: number; limit: number | null; resetAt: Date }; costMonthly: { current: number; limit: number | null; resetAt: Date }; + limitTotalUsd: { current: number; limit: number | null }; concurrentSessions: { current: number; limit: number }; }; @@ -2820,6 +2836,7 @@ export async function getProviderLimitUsageBatch( limitDailyUsd?: number | null; limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; }> ): Promise> { @@ -2845,7 +2862,9 @@ export async function getProviderLimitUsageBatch( getTimeRangeForPeriodWithMode, } = await import("@/lib/rate-limit/time-utils"); const { RateLimitService } = await import("@/lib/rate-limit"); - const { sumProviderCostInTimeRange } = await import("@/repository/statistics"); + const { sumProviderCostInTimeRange, sumProviderTotalCost } = await import( + "@/repository/statistics" + ); const providerIds = providers.map((p) => p.id); @@ -2871,7 +2890,7 @@ export async function getProviderLimitUsageBatch( ); // 并行查询该供应商的各周期消费(直接查询数据库) - const [cost5h, resetAt5h, costDaily, costWeekly, costMonthly] = await Promise.all([ + const [cost5h, resetAt5h, costDaily, costWeekly, costMonthly, totalCost] = await Promise.all([ limit5hResetMode === "fixed" ? RateLimitService.getCurrentCost( provider.id, @@ -2887,6 +2906,7 @@ export async function getProviderLimitUsageBatch( sumProviderCostInTimeRange(provider.id, rangeDaily.startTime, rangeDaily.endTime), sumProviderCostInTimeRange(provider.id, rangeWeekly.startTime, rangeWeekly.endTime), sumProviderCostInTimeRange(provider.id, rangeMonthly.startTime, rangeMonthly.endTime), + sumProviderTotalCost(provider.id), ]); const sessionCount = sessionCountMap.get(provider.id) || 0; @@ -2926,6 +2946,10 @@ export async function getProviderLimitUsageBatch( limit: provider.limitMonthlyUsd ?? null, resetAt: resetMonthly.resetAt!, }, + limitTotalUsd: { + current: totalCost, + limit: provider.limitTotalUsd ?? null, + }, concurrentSessions: { current: sessionCount, limit: provider.limitConcurrentSessions || 0, diff --git a/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx b/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx index b7ab7e873..079737651 100644 --- a/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx +++ b/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx @@ -32,6 +32,7 @@ interface KeyQuota { costDaily: { current: number; limit: number | null; resetAt?: Date }; costWeekly: { current: number; limit: number | null }; costMonthly: { current: number; limit: number | null }; + costTotal: { current: number; limit: number | null }; concurrentSessions: { current: number; limit: number }; } @@ -79,6 +80,9 @@ export function EditKeyQuotaDialog({ const [limitMonthly, setLimitMonthly] = useState( currentQuota?.costMonthly.limit?.toString() ?? "" ); + const [limitTotal, setLimitTotal] = useState( + currentQuota?.costTotal.limit?.toString() ?? "" + ); const [limitConcurrent, setLimitConcurrent] = useState( currentQuota?.concurrentSessions.limit?.toString() ?? "0" ); @@ -98,6 +102,7 @@ export function EditKeyQuotaDialog({ dailyResetTime: resetTime, limitWeeklyUsd: limitWeekly ? parseFloat(limitWeekly) : null, limitMonthlyUsd: limitMonthly ? parseFloat(limitMonthly) : null, + limitTotalUsd: limitTotal ? parseFloat(limitTotal) : null, limitConcurrentSessions: limitConcurrent ? parseInt(limitConcurrent, 10) : 0, }); @@ -127,6 +132,7 @@ export function EditKeyQuotaDialog({ dailyResetTime: resetTime, limitWeeklyUsd: null, limitMonthlyUsd: null, + limitTotalUsd: null, limitConcurrentSessions: 0, }); @@ -335,6 +341,32 @@ export function EditKeyQuotaDialog({ )} + {/* 总限额 */} +
+ + setLimitTotal(e.target.value)} + className="h-9" + /> + {currentQuota?.costTotal.limit && ( +

+ {t("limitTotalUsd.current", { + currency: currencySymbol, + current: Number(currentQuota.costTotal.current).toFixed(4), + limit: Number(currentQuota.costTotal.limit).toFixed(2), + })} +

+ )} +
+ {/* 并发限额 */}
diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx index 811cbb18e..aa67adc95 100644 --- a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx +++ b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx @@ -15,6 +15,7 @@ interface ProviderQuota { costWeekly: { current: number; limit: number | null; resetAt: Date }; costMonthly: { current: number; limit: number | null; resetAt: Date }; concurrentSessions: { current: number; limit: number }; + limitTotalUsd: { current: number; limit: number | null }; } interface ProviderWithQuota { @@ -35,10 +36,12 @@ interface ProvidersQuotaClientProps { currencyCode?: CurrencyCode; } -// 判断供应商是否设置了限额 +// 判断供应商是否设置了任意限额 function hasQuotaLimit(quota: ProviderQuota | null): boolean { if (!quota) return false; + return ( + (quota.limitTotalUsd.limit !== null && quota.limitTotalUsd.limit > 0) || (quota.cost5h.limit !== null && quota.cost5h.limit > 0) || (quota.costDaily.limit !== null && quota.costDaily.limit > 0) || (quota.costWeekly.limit !== null && quota.costWeekly.limit > 0) || @@ -70,6 +73,9 @@ function calculateMaxUsage(provider: ProviderWithQuota): number { (provider.quota.concurrentSessions.current / provider.quota.concurrentSessions.limit) * 100 ); } + if (provider.quota.limitTotalUsd.limit && provider.quota.limitTotalUsd.limit > 0) { + usages.push((provider.quota.limitTotalUsd.current / provider.quota.limitTotalUsd.limit) * 100); + } return usages.length > 0 ? Math.max(...usages) : 0; } diff --git a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx index 5590c85aa..0c7381d03 100644 --- a/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx +++ b/src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx @@ -16,6 +16,7 @@ interface ProviderQuota { costDaily: { current: number; limit: number | null; resetAt?: Date }; costWeekly: { current: number; limit: number | null; resetAt: Date }; costMonthly: { current: number; limit: number | null; resetAt: Date }; + limitTotalUsd: { current: number; limit: number | null }; concurrentSessions: { current: number; limit: number }; } diff --git a/src/app/[locale]/dashboard/quotas/providers/page.tsx b/src/app/[locale]/dashboard/quotas/providers/page.tsx index 6b74c94c0..4bb26d437 100644 --- a/src/app/[locale]/dashboard/quotas/providers/page.tsx +++ b/src/app/[locale]/dashboard/quotas/providers/page.tsx @@ -25,6 +25,7 @@ async function getProvidersWithQuotas() { limitDailyUsd: p.limitDailyUsd, limitWeeklyUsd: p.limitWeeklyUsd, limitMonthlyUsd: p.limitMonthlyUsd, + limitTotalUsd: p.limitTotalUsd, limitConcurrentSessions: p.limitConcurrentSessions, })) ); diff --git a/src/lib/utils/quota-helpers.ts b/src/lib/utils/quota-helpers.ts index 074481e92..94f2c5c12 100644 --- a/src/lib/utils/quota-helpers.ts +++ b/src/lib/utils/quota-helpers.ts @@ -10,6 +10,7 @@ export type KeyQuota = { costDaily: { current: number; limit: number | null }; costWeekly: { current: number; limit: number | null }; costMonthly: { current: number; limit: number | null }; + costTotal?: { current: number; limit: number | null }; concurrentSessions: { current: number; limit: number }; } | null; @@ -22,7 +23,7 @@ export type UserQuota = { * 判断密钥是否设置了限额 * * @param quota - 密钥限额数据 - * @returns 是否设置了任意限额(5h/周/月/并发) + * @returns 是否设置了任意限额(5h/日/周/月/总额/并发) */ export function hasKeyQuotaSet(quota: KeyQuota): boolean { if (!quota) return false; @@ -32,6 +33,7 @@ export function hasKeyQuotaSet(quota: KeyQuota): boolean { quota.costDaily.limit || quota.costWeekly.limit || quota.costMonthly.limit || + quota.costTotal?.limit || (quota.concurrentSessions.limit && quota.concurrentSessions.limit > 0) ); } @@ -87,6 +89,9 @@ export function getMaxUsageRate(quota: KeyQuota): number { if (quota.costMonthly.limit) { rates.push(getUsageRate(quota.costMonthly.current, quota.costMonthly.limit)); } + if (quota.costTotal?.limit) { + rates.push(getUsageRate(quota.costTotal.current, quota.costTotal.limit)); + } if (quota.concurrentSessions.limit > 0) { rates.push(getUsageRate(quota.concurrentSessions.current, quota.concurrentSessions.limit)); } diff --git a/tests/unit/actions/providers-usage.test.ts b/tests/unit/actions/providers-usage.test.ts index 74838c196..a755b8b95 100644 --- a/tests/unit/actions/providers-usage.test.ts +++ b/tests/unit/actions/providers-usage.test.ts @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getSessionMock = vi.fn(); const findProviderByIdMock = vi.fn(); const sumProviderCostInTimeRangeMock = vi.fn(); +const sumProviderTotalCostMock = vi.fn(); const getProviderSessionCountMock = vi.fn(); const getProviderSessionCountBatchMock = vi.fn(); const getTimeRangeForPeriodMock = vi.fn(); @@ -37,6 +38,7 @@ vi.mock("@/repository/provider", () => ({ vi.mock("@/repository/statistics", () => ({ sumProviderCostInTimeRange: (providerId: number, startTime: Date, endTime: Date) => sumProviderCostInTimeRangeMock(providerId, startTime, endTime), + sumProviderTotalCost: (providerId: number) => sumProviderTotalCostMock(providerId), })); vi.mock("@/lib/session-tracker", () => ({ @@ -166,6 +168,7 @@ describe("getProviderLimitUsage", () => { // Default DB costs sumProviderCostInTimeRangeMock.mockResolvedValue(5.5); + sumProviderTotalCostMock.mockResolvedValue(0); }); afterEach(() => { @@ -403,6 +406,7 @@ describe("getProviderLimitUsageBatch", () => { get5hWindowResetAtMock.mockResolvedValue(null); sumProviderCostInTimeRangeMock.mockResolvedValue(5.5); + sumProviderTotalCostMock.mockResolvedValue(0); }); afterEach(() => {