mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota
feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
@@ -3,9 +3,20 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
type UpdateSettingsRequest,
|
||||
type DefaultPlatformQuotasMap,
|
||||
} from "@/api/admin/settings";
|
||||
|
||||
/** 全 null 的 4 平台 map,用于断言归一化默认值 */
|
||||
const allNullQuotas: DefaultPlatformQuotasMap = {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
}
|
||||
|
||||
describe("admin settings auth source defaults helpers", () => {
|
||||
it("builds auth source defaults state from flat settings fields", () => {
|
||||
const state = buildAuthSourceDefaultsState({
|
||||
@@ -31,6 +42,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [{ group_id: 1, validity_days: 30 }],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.linuxdo).toEqual({
|
||||
balance: 6,
|
||||
@@ -38,6 +50,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [{ group_id: 2, validity_days: 60 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.oidc).toEqual({
|
||||
balance: 0,
|
||||
@@ -45,6 +58,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.wechat).toEqual({
|
||||
balance: 0,
|
||||
@@ -52,6 +66,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +79,23 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
expect(state.wechat.grant_on_signup).toBe(false);
|
||||
});
|
||||
|
||||
it("reads nested platform_quotas from settings into auth source state", () => {
|
||||
const state = buildAuthSourceDefaultsState({
|
||||
auth_source_default_email_platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: 50, monthly: 200 },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
} as DefaultPlatformQuotasMap,
|
||||
});
|
||||
|
||||
// anthropic 填写的值应被保留
|
||||
expect(state.email.platform_quotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
|
||||
// openai 全 null 应被保留
|
||||
expect(state.email.platform_quotas.openai).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
// 未出现的平台(gemini/antigravity)归一化为 null
|
||||
expect(state.email.platform_quotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(state.email.platform_quotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("appends auth source defaults back onto update payload", () => {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
site_name: "Sub2API",
|
||||
@@ -76,6 +108,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [{ group_id: 3, validity_days: 7 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
linuxdo: {
|
||||
balance: 0,
|
||||
@@ -83,6 +116,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: {},
|
||||
},
|
||||
oidc: {
|
||||
balance: 4,
|
||||
@@ -90,6 +124,7 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [{ group_id: 9, validity_days: 90 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: {},
|
||||
},
|
||||
wechat: {
|
||||
balance: 2,
|
||||
@@ -97,6 +132,31 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
github: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
google: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
dingtalk: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,6 +186,111 @@ describe("admin settings auth source defaults helpers", () => {
|
||||
auth_source_default_wechat_subscriptions: [],
|
||||
auth_source_default_wechat_grant_on_signup: false,
|
||||
auth_source_default_wechat_grant_on_first_bind: false,
|
||||
// 嵌套 platform_quotas 字段
|
||||
auth_source_default_email_platform_quotas: allNullQuotas,
|
||||
auth_source_default_linuxdo_platform_quotas: allNullQuotas,
|
||||
auth_source_default_oidc_platform_quotas: allNullQuotas,
|
||||
auth_source_default_wechat_platform_quotas: allNullQuotas,
|
||||
auth_source_default_github_platform_quotas: allNullQuotas,
|
||||
auth_source_default_google_platform_quotas: allNullQuotas,
|
||||
auth_source_default_dingtalk_platform_quotas: allNullQuotas,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends sanitized nested platform_quotas with non-null values in update payload", () => {
|
||||
const payload: UpdateSettingsRequest = {};
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, {
|
||||
email: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: 50, monthly: 200 },
|
||||
openai: { daily: 0, weekly: null, monthly: null },
|
||||
},
|
||||
},
|
||||
linuxdo: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
oidc: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
wechat: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
github: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
google: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
dingtalk: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
});
|
||||
|
||||
const emailQuotas = (payload as Record<string, unknown>)["auth_source_default_email_platform_quotas"] as DefaultPlatformQuotasMap;
|
||||
expect(emailQuotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
|
||||
// 0 是合法值(不限额=0 与"不设"不同,保留)
|
||||
expect(emailQuotas.openai?.daily).toBe(0);
|
||||
// 缺失平台归一化为全 null
|
||||
expect(emailQuotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(emailQuotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePlatformQuotasMap", () => {
|
||||
it("填充缺失的平台为全 null 三档", () => {
|
||||
const result = normalizePlatformQuotasMap({ anthropic: { daily: 5, weekly: null, monthly: null } });
|
||||
expect(result.anthropic).toEqual({ daily: 5, weekly: null, monthly: null });
|
||||
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("无参数时返回全 4 平台全 null", () => {
|
||||
const result = normalizePlatformQuotasMap();
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("非 number 类型的值归一化为 null", () => {
|
||||
const result = normalizePlatformQuotasMap({
|
||||
anthropic: { daily: "50" as unknown as number, weekly: undefined as unknown as number, monthly: null },
|
||||
});
|
||||
expect(result.anthropic).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePlatformQuotasMap", () => {
|
||||
it("保留合法的正数和零值", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
anthropic: { daily: 10.5, weekly: 0, monthly: null },
|
||||
});
|
||||
expect(result.anthropic?.daily).toBe(10.5);
|
||||
expect(result.anthropic?.weekly).toBe(0);
|
||||
expect(result.anthropic?.monthly).toBe(null);
|
||||
});
|
||||
|
||||
it("空字符串(v-model.number 空输入)清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
anthropic: { daily: "" as unknown as number, weekly: null, monthly: null },
|
||||
});
|
||||
expect(result.anthropic?.daily).toBe(null);
|
||||
});
|
||||
|
||||
it("负数清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
openai: { daily: -1, weekly: null, monthly: null },
|
||||
});
|
||||
expect(result.openai?.daily).toBe(null);
|
||||
});
|
||||
|
||||
it("NaN/Infinity 清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
gemini: { daily: NaN, weekly: Infinity, monthly: null },
|
||||
});
|
||||
expect(result.gemini?.daily).toBe(null);
|
||||
expect(result.gemini?.weekly).toBe(null);
|
||||
});
|
||||
|
||||
it("缺失平台填充为全 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({});
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,47 @@ export interface DefaultSubscriptionSetting {
|
||||
validity_days: number;
|
||||
}
|
||||
|
||||
// ── 平台限额类型 ──────────────────────────────────────────────────
|
||||
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity"
|
||||
export type QuotaWindowType = "daily" | "weekly" | "monthly"
|
||||
|
||||
/** 单平台三档限额;null = 不限制,undefined = 未填(等价 null) */
|
||||
export interface PlatformQuotaLimits {
|
||||
daily: number | null
|
||||
weekly: number | null
|
||||
monthly: number | null
|
||||
}
|
||||
|
||||
/** 全平台默认限额 map(key = PlatformType) */
|
||||
export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuotaLimits>>
|
||||
|
||||
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity"]
|
||||
|
||||
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
|
||||
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
|
||||
const result: DefaultPlatformQuotasMap = {}
|
||||
for (const p of PLATFORMS) {
|
||||
const src = input?.[p]
|
||||
result[p] = {
|
||||
daily: typeof src?.daily === "number" ? src.daily : null,
|
||||
weekly: typeof src?.weekly === "number" ? src.weekly : null,
|
||||
monthly: typeof src?.monthly === "number" ? src.monthly : null,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 提交前清洗:非有限数/负数/空字符串 → null(保留 0 = 显式禁用),返回全 4 平台嵌套 map */
|
||||
export function sanitizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
|
||||
const clean = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null)
|
||||
const result: DefaultPlatformQuotasMap = {}
|
||||
for (const p of PLATFORMS) {
|
||||
const src = input?.[p]
|
||||
result[p] = { daily: clean(src?.daily), weekly: clean(src?.weekly), monthly: clean(src?.monthly) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type AuthSourceType =
|
||||
| "email"
|
||||
| "linuxdo"
|
||||
@@ -31,6 +72,8 @@ export interface AuthSourceDefaultsValue {
|
||||
subscriptions: DefaultSubscriptionSetting[];
|
||||
grant_on_signup: boolean;
|
||||
grant_on_first_bind: boolean;
|
||||
// ★ 新增:平台限额覆盖(key = PlatformType)
|
||||
platform_quotas: DefaultPlatformQuotasMap;
|
||||
}
|
||||
|
||||
export type AuthSourceDefaultsState = Record<
|
||||
@@ -193,6 +236,7 @@ export function buildAuthSourceDefaultsState(
|
||||
raw[`auth_source_default_${source}_grant_on_signup`] === true,
|
||||
grant_on_first_bind:
|
||||
raw[`auth_source_default_${source}_grant_on_first_bind`] === true,
|
||||
platform_quotas: normalizePlatformQuotasMap(raw[`auth_source_default_${source}_platform_quotas`] as DefaultPlatformQuotasMap | undefined),
|
||||
};
|
||||
return acc;
|
||||
}, {} as AuthSourceDefaultsState);
|
||||
@@ -220,6 +264,7 @@ export function appendAuthSourceDefaultsToUpdateRequest(
|
||||
current.grant_on_signup;
|
||||
target[`auth_source_default_${source}_grant_on_first_bind`] =
|
||||
current.grant_on_first_bind;
|
||||
target[`auth_source_default_${source}_platform_quotas`] = sanitizePlatformQuotasMap(current.platform_quotas)
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -370,6 +415,15 @@ export interface SystemSettings {
|
||||
auth_source_default_google_grant_on_signup?: boolean;
|
||||
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||
force_email_on_third_party_signup?: boolean;
|
||||
// ── 平台限额(嵌套 JSON,系统层 + 7 auth-source 层)────────────────────────────────
|
||||
default_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_email_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_linuxdo_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_oidc_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_wechat_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_github_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_google_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_dingtalk_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
// OEM settings
|
||||
site_name: string;
|
||||
site_logo: string;
|
||||
@@ -616,6 +670,15 @@ export interface UpdateSettingsRequest {
|
||||
auth_source_default_google_grant_on_signup?: boolean;
|
||||
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||
force_email_on_third_party_signup?: boolean;
|
||||
// ── 平台限额(嵌套 JSON,系统层 + 7 auth-source 层)────────────────────────────────
|
||||
default_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_email_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_linuxdo_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_oidc_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_wechat_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_github_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_google_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
auth_source_default_dingtalk_platform_quotas?: DefaultPlatformQuotasMap;
|
||||
site_name?: string;
|
||||
site_logo?: string;
|
||||
site_subtitle?: string;
|
||||
|
||||
@@ -297,6 +297,78 @@ export async function bindUserAuthIdentity(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform quota types
|
||||
*/
|
||||
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export interface PlatformQuotaItem {
|
||||
platform: PlatformQuotaPlatform
|
||||
daily_limit_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
daily_usage_usd: number
|
||||
weekly_usage_usd: number
|
||||
monthly_usage_usd: number
|
||||
daily_window_start?: string | null
|
||||
weekly_window_start?: string | null
|
||||
monthly_window_start?: string | null
|
||||
daily_window_resets_at?: string | null
|
||||
weekly_window_resets_at?: string | null
|
||||
monthly_window_resets_at?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformQuotaUpdateItem {
|
||||
platform: PlatformQuotaPlatform
|
||||
daily_limit_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
}
|
||||
|
||||
export interface PlatformQuotasResponse {
|
||||
platform_quotas: PlatformQuotaItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's platform quotas
|
||||
*/
|
||||
export async function getPlatformQuotas(id: number): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.get<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace user's platform quotas (全量替换)
|
||||
*/
|
||||
export async function updatePlatformQuotas(
|
||||
id: number,
|
||||
quotas: PlatformQuotaUpdateItem[]
|
||||
): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.put<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas`,
|
||||
{ quotas }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a single (platform, window) usage immediately
|
||||
*/
|
||||
export async function resetPlatformQuotaWindow(
|
||||
id: number,
|
||||
platform: PlatformQuotaPlatform,
|
||||
window: PlatformQuotaWindow
|
||||
): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.post<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas/reset`,
|
||||
{ platform, window }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
list,
|
||||
getById,
|
||||
@@ -310,7 +382,10 @@ export const usersAPI = {
|
||||
getUserUsageStats,
|
||||
getUserBalanceHistory,
|
||||
replaceGroup,
|
||||
bindUserAuthIdentity
|
||||
bindUserAuthIdentity,
|
||||
getPlatformQuotas,
|
||||
updatePlatformQuotas,
|
||||
resetPlatformQuotaWindow,
|
||||
}
|
||||
|
||||
export default usersAPI
|
||||
|
||||
@@ -15,7 +15,8 @@ import type {
|
||||
NotifyEmailEntry,
|
||||
UserAuthProvider,
|
||||
UserAffiliateDetail,
|
||||
AffiliateTransferResponse
|
||||
AffiliateTransferResponse,
|
||||
PlatformQuotasResponse,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -185,6 +186,14 @@ export async function transferAffiliateQuota(): Promise<AffiliateTransferRespons
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的平台限额 + 用量。
|
||||
*/
|
||||
export async function getMyPlatformQuotas(): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.get<PlatformQuotasResponse>('/user/platform-quotas')
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -199,7 +208,8 @@ export const userAPI = {
|
||||
buildOAuthBindingStartURL,
|
||||
startOAuthBinding,
|
||||
getAffiliateDetail,
|
||||
transferAffiliateQuota
|
||||
transferAffiliateQuota,
|
||||
getMyPlatformQuotas,
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.users.platformQuota.title')"
|
||||
width="wide"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div v-if="user" class="space-y-4">
|
||||
<div
|
||||
v-if="hasActiveSubscription"
|
||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
{{ t('admin.users.platformQuota.subscriptionWarning') }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.users.platformQuota.subtitle', { email: user.email }) }}
|
||||
</p>
|
||||
<div v-if="loading" class="py-10 text-center text-gray-500">{{ t('common.loading') }}</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-700 dark:border-dark-700 dark:text-gray-300">
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('admin.users.platformQuota.columns.platform') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('admin.users.platformQuota.columns.daily') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('admin.users.platformQuota.columns.weekly') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('admin.users.platformQuota.columns.monthly') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('admin.users.platformQuota.columns.usage') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in quotas" :key="row.platform" class="border-b border-gray-100 dark:border-dark-800">
|
||||
<td class="px-3 py-2 font-mono text-gray-900 dark:text-white">{{ row.platform }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
v-model.number="row.daily_limit_usd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input w-24"
|
||||
:placeholder="t('admin.users.platformQuota.placeholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-400 hover:text-amber-500 disabled:opacity-50"
|
||||
:disabled="!!resetting[`${row.platform}.daily`]"
|
||||
:title="t('admin.users.platformQuota.reset.button')"
|
||||
@click="onReset(row.platform, 'daily')"
|
||||
>↻</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
v-model.number="row.weekly_limit_usd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input w-24"
|
||||
:placeholder="t('admin.users.platformQuota.placeholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-400 hover:text-amber-500 disabled:opacity-50"
|
||||
:disabled="!!resetting[`${row.platform}.weekly`]"
|
||||
:title="t('admin.users.platformQuota.reset.button')"
|
||||
@click="onReset(row.platform, 'weekly')"
|
||||
>↻</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
v-model.number="row.monthly_limit_usd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input w-24"
|
||||
:placeholder="t('admin.users.platformQuota.placeholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-400 hover:text-amber-500 disabled:opacity-50"
|
||||
:disabled="!!resetting[`${row.platform}.monthly`]"
|
||||
:title="t('admin.users.platformQuota.reset.button')"
|
||||
@click="onReset(row.platform, 'monthly')"
|
||||
>↻</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatUsage(row.daily_usage_usd) }} / {{ formatUsage(row.weekly_usage_usd) }} / {{ formatUsage(row.monthly_usage_usd) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-3 text-xs text-gray-500">{{ t('admin.users.platformQuota.hint') }}</p>
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-secondary text-sm" @click="onClearAll">
|
||||
{{ t('admin.users.platformQuota.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('admin.users.platformQuota.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="submitting || loading" @click="onSave">
|
||||
{{ submitting ? t('admin.users.platformQuota.saving') : t('admin.users.platformQuota.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { AdminUser, PlatformQuotaItem, PlatformQuotaPlatform, PlatformQuotaWindow } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
|
||||
interface QuotaRow {
|
||||
platform: PlatformQuotaPlatform
|
||||
daily_limit_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
daily_usage_usd: number
|
||||
weekly_usage_usd: number
|
||||
monthly_usage_usd: number
|
||||
}
|
||||
|
||||
const hasActiveSubscription = computed(() =>
|
||||
props.user?.subscriptions?.some((s) => s.status === 'active') ?? false
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const resetting = reactive<Record<string, boolean>>({})
|
||||
const quotas = ref<QuotaRow[]>([])
|
||||
|
||||
function emptyRow(p: PlatformQuotaPlatform): QuotaRow {
|
||||
return {
|
||||
platform: p,
|
||||
daily_limit_usd: null,
|
||||
weekly_limit_usd: null,
|
||||
monthly_limit_usd: null,
|
||||
daily_usage_usd: 0,
|
||||
weekly_usage_usd: 0,
|
||||
monthly_usage_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(items: PlatformQuotaItem[]): QuotaRow[] {
|
||||
const byPlatform = new Map<PlatformQuotaPlatform, PlatformQuotaItem>()
|
||||
for (const it of items) byPlatform.set(it.platform, it)
|
||||
return PLATFORMS.map((p) => {
|
||||
const it = byPlatform.get(p)
|
||||
if (!it) return emptyRow(p)
|
||||
return {
|
||||
platform: p,
|
||||
daily_limit_usd: it.daily_limit_usd ?? null,
|
||||
weekly_limit_usd: it.weekly_limit_usd ?? null,
|
||||
monthly_limit_usd: it.monthly_limit_usd ?? null,
|
||||
daily_usage_usd: it.daily_usage_usd ?? 0,
|
||||
weekly_usage_usd: it.weekly_usage_usd ?? 0,
|
||||
monthly_usage_usd: it.monthly_usage_usd ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatUsage(n: number): string {
|
||||
if (n == null || Number.isNaN(n)) return '-'
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.user) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminAPI.users.getPlatformQuotas(props.user.id)
|
||||
quotas.value = normalize(data.platform_quotas || [])
|
||||
} catch {
|
||||
appStore.showError(t('admin.users.platformQuota.loadFailed'))
|
||||
quotas.value = PLATFORMS.map(emptyRow)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(s) => { if (s && props.user) load() },
|
||||
)
|
||||
|
||||
function onClearAll() {
|
||||
// 二次确认:一键清空全部平台的 daily/weekly/monthly 限额属于高风险批量操作,
|
||||
// 误点后所有平台变为"无限额",且本地无 undo 机制(需要逐个手动重填或取消保存)。
|
||||
const confirmed = window.confirm(t('admin.users.platformQuota.clearAllConfirm'))
|
||||
if (!confirmed) return
|
||||
for (const row of quotas.value) {
|
||||
row.daily_limit_usd = null
|
||||
row.weekly_limit_usd = null
|
||||
row.monthly_limit_usd = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!props.user) return
|
||||
// 校验所有 input:v-model.number 在用户输入"0."等中间状态时会写回 NaN,
|
||||
// 之前的 normalizeLimit(NaN) 静默返回 null("无限制"),把"有限额"配置悄悄改成"无限制"。
|
||||
// 这里在 save 前显式检测 NaN,提示用户修正后再提交。
|
||||
const invalid: string[] = []
|
||||
for (const row of quotas.value) {
|
||||
for (const win of ['daily', 'weekly', 'monthly'] as const) {
|
||||
const v = row[`${win}_limit_usd` as const]
|
||||
if (typeof v === 'number' && Number.isNaN(v)) {
|
||||
invalid.push(`${row.platform}.${win}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invalid.length > 0) {
|
||||
appStore.showError(t('admin.users.platformQuota.invalidNumber', { fields: invalid.join(', ') }))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = quotas.value.map((r) => ({
|
||||
platform: r.platform,
|
||||
daily_limit_usd: normalizeLimit(r.daily_limit_usd),
|
||||
weekly_limit_usd: normalizeLimit(r.weekly_limit_usd),
|
||||
monthly_limit_usd: normalizeLimit(r.monthly_limit_usd),
|
||||
}))
|
||||
await adminAPI.users.updatePlatformQuotas(props.user.id, payload)
|
||||
appStore.showSuccess(t('admin.users.platformQuota.updateSuccess'))
|
||||
emit('success')
|
||||
emit('close')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e?.response?.data?.message || t('admin.users.platformQuota.updateFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在合法输入下返回数字:null/undefined/NaN/±Inf/负数 → null(视为"无限额")。
|
||||
// 调用方负责在 NaN 路径上做单独的用户提示(见 onSave)。
|
||||
function normalizeLimit(v: number | null | undefined): number | null {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v
|
||||
return null
|
||||
}
|
||||
|
||||
async function onReset(platform: PlatformQuotaPlatform, quotaWindow: PlatformQuotaWindow) {
|
||||
if (!props.user) return
|
||||
const windowLabel = t(`admin.users.platformQuota.window${quotaWindow.charAt(0).toUpperCase() + quotaWindow.slice(1)}`)
|
||||
const confirmed = window.confirm(
|
||||
t('admin.users.platformQuota.reset.confirm', { platform, window: windowLabel })
|
||||
)
|
||||
if (!confirmed) return
|
||||
const key = `${platform}.${quotaWindow}`
|
||||
resetting[key] = true
|
||||
try {
|
||||
const data = await adminAPI.users.resetPlatformQuotaWindow(props.user.id, platform, quotaWindow)
|
||||
quotas.value = normalize(data.platform_quotas || [])
|
||||
appStore.showSuccess(t('admin.users.platformQuota.reset.success', { platform, window: windowLabel }))
|
||||
} catch (e: any) {
|
||||
appStore.showError(e?.response?.data?.message || t('admin.users.platformQuota.reset.failed'))
|
||||
} finally {
|
||||
resetting[key] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getPlatformQuotas: vi.fn(),
|
||||
updatePlatformQuotas: vi.fn(),
|
||||
resetPlatformQuotaWindow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
users: {
|
||||
getPlatformQuotas: apiMocks.getPlatformQuotas,
|
||||
updatePlatformQuotas: apiMocks.updatePlatformQuotas,
|
||||
resetPlatformQuotaWindow: apiMocks.resetPlatformQuotaWindow,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) {
|
||||
return key.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? '')
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/common/BaseDialog.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseDialog',
|
||||
props: ['show', 'title', 'width'],
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
import UserPlatformQuotaModal from '../UserPlatformQuotaModal.vue'
|
||||
import type { UserSubscription } from '@/types'
|
||||
|
||||
function makeUser(overrides: { subscriptions?: UserSubscription[] } = {}) {
|
||||
return { id: 99, email: 'u@example.com', ...overrides } as any
|
||||
}
|
||||
|
||||
/** 挂载并触发 show:false → true,确保 watch 被激活 */
|
||||
async function mountAndOpen(extraProps: Record<string, unknown> = {}) {
|
||||
const w = mount(UserPlatformQuotaModal, {
|
||||
props: { show: false, user: makeUser(), ...extraProps },
|
||||
})
|
||||
await w.setProps({ show: true })
|
||||
await flushPromises()
|
||||
return w
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
apiMocks.getPlatformQuotas.mockResolvedValue({ platform_quotas: [] })
|
||||
apiMocks.updatePlatformQuotas.mockResolvedValue({ platform_quotas: [] })
|
||||
apiMocks.resetPlatformQuotaWindow.mockResolvedValue({ platform_quotas: [] })
|
||||
})
|
||||
|
||||
describe('UserPlatformQuotaModal', () => {
|
||||
it('挂载并 show=true 时调用 getPlatformQuotas', async () => {
|
||||
await mountAndOpen()
|
||||
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('空数据渲染 4 个 platform 行', async () => {
|
||||
const w = await mountAndOpen()
|
||||
const html = w.html()
|
||||
expect(html).toContain('anthropic')
|
||||
expect(html).toContain('openai')
|
||||
expect(html).toContain('gemini')
|
||||
expect(html).toContain('antigravity')
|
||||
})
|
||||
|
||||
it('已有数据正确填充 limit input', async () => {
|
||||
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
|
||||
platform_quotas: [
|
||||
{ platform: 'anthropic', daily_limit_usd: 10, weekly_limit_usd: null, monthly_limit_usd: null,
|
||||
daily_usage_usd: 3.2, weekly_usage_usd: 0, monthly_usage_usd: 0 },
|
||||
],
|
||||
})
|
||||
const w = await mountAndOpen()
|
||||
const inputs = w.findAll('input[type=number]')
|
||||
// 4 platforms × 3 windows = 12 inputs
|
||||
expect(inputs.length).toBe(12)
|
||||
// 第一个 input 是 anthropic.daily = 10
|
||||
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
|
||||
})
|
||||
|
||||
it('保存提交完整 4 platform payload', async () => {
|
||||
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
|
||||
platform_quotas: [
|
||||
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0 },
|
||||
],
|
||||
})
|
||||
const w = await mountAndOpen()
|
||||
// 找到「保存」按钮(包含中文「保存」字样的按钮)
|
||||
const buttons = w.findAll('button')
|
||||
const saveBtn = buttons.find((b) => b.text() === 'admin.users.platformQuota.save')
|
||||
expect(saveBtn).toBeTruthy()
|
||||
await saveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
|
||||
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
|
||||
expect(uid).toBe(99)
|
||||
expect(payload).toHaveLength(4) // 4 platforms always submitted
|
||||
const openai = payload.find((p: any) => p.platform === 'openai')
|
||||
expect(openai.weekly_limit_usd).toBe(20)
|
||||
})
|
||||
|
||||
it('全部清空把所有 limit 置 null(确认通过)', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
|
||||
platform_quotas: [
|
||||
{ platform: 'anthropic', daily_limit_usd: 10, weekly_limit_usd: 50, monthly_limit_usd: 100,
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0 },
|
||||
],
|
||||
})
|
||||
const w = await mountAndOpen()
|
||||
const buttons = w.findAll('button')
|
||||
const clearBtn = buttons.find((b) => b.text() === 'admin.users.platformQuota.clearAll')
|
||||
expect(clearBtn).toBeTruthy()
|
||||
await clearBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||
const inputs = w.findAll('input[type=number]')
|
||||
for (const inp of inputs) {
|
||||
expect((inp.element as HTMLInputElement).value).toBe('')
|
||||
}
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('全部清空 confirm 取消则保持原值', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
|
||||
platform_quotas: [
|
||||
{ platform: 'anthropic', daily_limit_usd: 10, weekly_limit_usd: 50, monthly_limit_usd: 100,
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0 },
|
||||
],
|
||||
})
|
||||
const w = await mountAndOpen()
|
||||
const clearBtn = w.findAll('button').find((b) => b.text() === 'admin.users.platformQuota.clearAll')
|
||||
await clearBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||
// anthropic daily 应保持 10(未被清空)
|
||||
const inputs = w.findAll('input[type=number]')
|
||||
const dailyVal = (inputs[0].element as HTMLInputElement).value
|
||||
expect(dailyVal).toBe('10')
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('重置按钮 confirm 取消则不调用 API', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const w = await mountAndOpen()
|
||||
const resetBtns = w.findAll('button').filter((b) => b.text() === '↻')
|
||||
expect(resetBtns.length).toBeGreaterThan(0)
|
||||
await resetBtns[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(apiMocks.resetPlatformQuotaWindow).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('重置按钮 confirm 确认则调用 API', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const w = await mountAndOpen()
|
||||
const resetBtns = w.findAll('button').filter((b) => b.text() === '↻')
|
||||
await resetBtns[0].trigger('click') // 第一个是 anthropic.daily
|
||||
await flushPromises()
|
||||
expect(apiMocks.resetPlatformQuotaWindow).toHaveBeenCalledWith(99, 'anthropic', 'daily')
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('subscription warning banner', () => {
|
||||
it('displays subscription warning when user has active subscription', async () => {
|
||||
const w = mount(UserPlatformQuotaModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user: makeUser({
|
||||
subscriptions: [
|
||||
{
|
||||
id: 1, user_id: 99, group_id: 1, status: 'active',
|
||||
starts_at: '2026-01-01T00:00:00Z', expires_at: null,
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0,
|
||||
daily_window_start: null, weekly_window_start: null, monthly_window_start: null,
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
} as UserSubscription,
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
expect(w.html()).toContain('admin.users.platformQuota.subscriptionWarning')
|
||||
})
|
||||
|
||||
it('hides subscription warning when user has only expired subscriptions', async () => {
|
||||
const w = mount(UserPlatformQuotaModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user: makeUser({
|
||||
subscriptions: [
|
||||
{
|
||||
id: 2, user_id: 99, group_id: 1, status: 'expired',
|
||||
starts_at: '2025-01-01T00:00:00Z', expires_at: '2025-12-31T00:00:00Z',
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0,
|
||||
daily_window_start: null, weekly_window_start: null, monthly_window_start: null,
|
||||
created_at: '2025-01-01T00:00:00Z', updated_at: '2025-12-31T00:00:00Z',
|
||||
} as UserSubscription,
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
expect(w.html()).not.toContain('admin.users.platformQuota.subscriptionWarning')
|
||||
})
|
||||
|
||||
it('hides subscription warning when subscriptions is empty array', async () => {
|
||||
const w = mount(UserPlatformQuotaModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user: makeUser({ subscriptions: [] }),
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
expect(w.html()).not.toContain('admin.users.platformQuota.subscriptionWarning')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<span v-if="props.quotas === undefined" class="text-xs text-gray-400 dark:text-gray-500">…</span>
|
||||
<span v-else-if="configured.length === 0" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.users.platformQuota.cellNotConfigured') }}
|
||||
</span>
|
||||
<div v-else class="space-y-0.5 text-xs">
|
||||
<div
|
||||
v-for="row in configured"
|
||||
:key="row.platform"
|
||||
class="flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<span class="w-20 shrink-0 font-mono text-gray-700 dark:text-gray-300">{{ row.platform }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.users.platformQuota.windowDaily') }}
|
||||
<span class="text-gray-900 dark:text-white">{{ fmtUsd(row.daily_usage_usd) }}/{{ fmtLimit(row.daily_limit_usd) }}</span>
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.users.platformQuota.windowWeekly') }}
|
||||
<span class="text-gray-900 dark:text-white">{{ fmtUsd(row.weekly_usage_usd) }}/{{ fmtLimit(row.weekly_limit_usd) }}</span>
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.users.platformQuota.windowMonthly') }}
|
||||
<span class="text-gray-900 dark:text-white">{{ fmtUsd(row.monthly_usage_usd) }}/{{ fmtLimit(row.monthly_limit_usd) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PlatformQuotaItem, PlatformQuotaPlatform } from '@/api/admin/users'
|
||||
|
||||
const props = defineProps<{ quotas?: PlatformQuotaItem[] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
|
||||
// 仅展示「至少一档限额非空」的平台(配额列,非用量列)
|
||||
const configured = computed(() => {
|
||||
if (!props.quotas) return []
|
||||
return props.quotas
|
||||
.filter(
|
||||
(q) =>
|
||||
q.daily_limit_usd != null ||
|
||||
q.weekly_limit_usd != null ||
|
||||
q.monthly_limit_usd != null
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => PLATFORM_ORDER.indexOf(a.platform) - PLATFORM_ORDER.indexOf(b.platform))
|
||||
})
|
||||
|
||||
// 去尾零、最多 2 位小数:100→"100",90.5→"90.5",0.42→"0.42"
|
||||
function fmtUsd(n: number): string {
|
||||
if (n == null || Number.isNaN(n)) return '0'
|
||||
return String(Math.round(n * 100) / 100)
|
||||
}
|
||||
function fmtLimit(n: number | null): string {
|
||||
return n == null ? '—' : fmtUsd(n)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
// t() 回显 key,便于断言文案键
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
import UserPlatformQuotaCell from '../UserPlatformQuotaCell.vue'
|
||||
import type { PlatformQuotaItem } from '@/api/admin/users'
|
||||
|
||||
function item(over: Partial<PlatformQuotaItem> & { platform: PlatformQuotaItem['platform'] }): PlatformQuotaItem {
|
||||
return {
|
||||
daily_limit_usd: null, weekly_limit_usd: null, monthly_limit_usd: null,
|
||||
daily_usage_usd: 0, weekly_usage_usd: 0, monthly_usage_usd: 0,
|
||||
...over,
|
||||
} as PlatformQuotaItem
|
||||
}
|
||||
|
||||
describe('UserPlatformQuotaCell', () => {
|
||||
it('quotas 为 undefined 时渲染加载占位 …', () => {
|
||||
const w = mount(UserPlatformQuotaCell, { props: { quotas: undefined } })
|
||||
expect(w.text()).toContain('…')
|
||||
expect(w.html()).not.toContain('admin.users.platformQuota.cellNotConfigured')
|
||||
})
|
||||
|
||||
it('空数组渲染「未配置」', () => {
|
||||
const w = mount(UserPlatformQuotaCell, { props: { quotas: [] } })
|
||||
expect(w.html()).toContain('admin.users.platformQuota.cellNotConfigured')
|
||||
})
|
||||
|
||||
it('平台有记录但全部 limit 为 null 时视为未配置', () => {
|
||||
const w = mount(UserPlatformQuotaCell, {
|
||||
props: { quotas: [item({ platform: 'openai', daily_usage_usd: 5 })] },
|
||||
})
|
||||
expect(w.html()).toContain('admin.users.platformQuota.cellNotConfigured')
|
||||
})
|
||||
|
||||
it('已配置平台渲染 已用/限额,null 档显示 —,金额去尾零', () => {
|
||||
const w = mount(UserPlatformQuotaCell, {
|
||||
props: {
|
||||
quotas: [
|
||||
item({ platform: 'anthropic', daily_limit_usd: 100, daily_usage_usd: 30,
|
||||
weekly_limit_usd: null, weekly_usage_usd: 0,
|
||||
monthly_limit_usd: 2000, monthly_usage_usd: 90.5 }),
|
||||
],
|
||||
},
|
||||
})
|
||||
const html = w.html()
|
||||
expect(html).toContain('anthropic')
|
||||
expect(html).toContain('30/100')
|
||||
expect(html).toContain('0/—')
|
||||
expect(html).toContain('90.5/2000')
|
||||
})
|
||||
|
||||
it('多平台按 anthropic→openai→gemini→antigravity 顺序,仅展示有限额的', () => {
|
||||
const w = mount(UserPlatformQuotaCell, {
|
||||
props: {
|
||||
quotas: [
|
||||
item({ platform: 'gemini', monthly_limit_usd: 50 }),
|
||||
item({ platform: 'anthropic', daily_limit_usd: 10 }),
|
||||
item({ platform: 'openai', daily_usage_usd: 9 }),
|
||||
],
|
||||
},
|
||||
})
|
||||
const text = w.text()
|
||||
expect(text.indexOf('anthropic')).toBeLessThan(text.indexOf('gemini'))
|
||||
expect(text).not.toContain('openai')
|
||||
})
|
||||
})
|
||||
@@ -177,6 +177,46 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota 区:仅当 quota 配置存在、非 __other__ 且至少有一个窗口配了 limit 时显示 -->
|
||||
<div v-if="hasAnyLimit(item.quota) && !item.isOther" class="mt-3 space-y-1.5 border-t border-gray-200 pt-2 dark:border-dark-700">
|
||||
<p class="text-[10px] uppercase tracking-wide text-gray-400">
|
||||
{{ t('dashboard.platformQuota.title') }}
|
||||
</p>
|
||||
<template v-for="w in (['daily', 'weekly', 'monthly'] as const)" :key="w">
|
||||
<div v-if="quotaVal(item.quota, `${w}_limit_usd`) != null" class="space-y-0.5">
|
||||
<!-- limit=0:完全禁用 -->
|
||||
<template v-if="(quotaVal(item.quota, `${w}_limit_usd`) as number) === 0">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ t(`dashboard.platformQuota.${w}`) }}</span>
|
||||
<span class="font-mono text-red-500">{{ t('dashboard.platformQuota.disabled') }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div class="h-full w-full rounded-full bg-red-500" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- limit>0:正常用量进度条 -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ t(`dashboard.platformQuota.${w}`) }}</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-200">
|
||||
${{ formatUsd((quotaVal(item.quota, `${w}_usage_usd`) as number) ?? 0) }} / ${{ formatUsd(quotaVal(item.quota, `${w}_limit_usd`) as number) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="quotaBarClass(calcPercent((quotaVal(item.quota, `${w}_usage_usd`) as number) ?? 0, quotaVal(item.quota, `${w}_limit_usd`) as number))"
|
||||
:style="{ width: calcPercent((quotaVal(item.quota, `${w}_usage_usd`) as number) ?? 0, quotaVal(item.quota, `${w}_limit_usd`) as number) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="quotaVal(item.quota, `${w}_window_resets_at`)" class="text-[10px] text-gray-400">
|
||||
{{ t('dashboard.platformQuota.resetsAt', { time: formatResetTime(quotaVal(item.quota, `${w}_window_resets_at`) as string) }) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,11 +227,23 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
|
||||
import type { PlatformQuotaItem } from '@/types'
|
||||
|
||||
interface FusedPlatformCard {
|
||||
platform: string
|
||||
total_actual_cost: number
|
||||
today_actual_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
isOther?: boolean
|
||||
quota?: PlatformQuotaItem
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
stats: UserStatsType
|
||||
balance: number
|
||||
isSimple: boolean
|
||||
platformQuotas?: PlatformQuotaItem[] | null
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -213,16 +265,45 @@ const sortedPlatforms = computed(() => {
|
||||
// (group 与 account 都缺 platform)。这里把差值作为"其他"卡片显式展示,
|
||||
// 避免 Row 1 总值与 Row 3 平台拆分加总对不上、用户困惑。
|
||||
const OTHER_THRESHOLD = 0.0001
|
||||
const platformCards = computed(() => {
|
||||
const cards: Array<{
|
||||
platform: string
|
||||
total_actual_cost: number
|
||||
today_actual_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
isOther?: boolean
|
||||
}> = sortedPlatforms.value.map((p) => ({ ...p }))
|
||||
const platformCards = computed<FusedPlatformCard[]>(() => {
|
||||
// 建立 by_platform Map
|
||||
const byPlat = new Map<string, (typeof sortedPlatforms.value)[number]>()
|
||||
for (const item of props.stats?.by_platform ?? []) byPlat.set(item.platform, item)
|
||||
|
||||
// 建立 quota Map
|
||||
const byQuota = new Map<string, PlatformQuotaItem>()
|
||||
for (const q of props.platformQuotas ?? []) byQuota.set(q.platform, q)
|
||||
|
||||
// union 平台集合。后端 by_platform / quota 接口均不会返回 platform='__other__',
|
||||
// 无需显式排除;__other__ 由下方差值补差逻辑单独追加。
|
||||
const platforms = new Set<string>([...byPlat.keys(), ...byQuota.keys()])
|
||||
|
||||
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const cards: FusedPlatformCard[] = []
|
||||
|
||||
for (const p of platforms) {
|
||||
const stat = byPlat.get(p)
|
||||
cards.push({
|
||||
platform: p,
|
||||
total_actual_cost: stat?.total_actual_cost ?? 0,
|
||||
today_actual_cost: stat?.today_actual_cost ?? 0,
|
||||
total_requests: stat?.total_requests ?? 0,
|
||||
total_tokens: stat?.total_tokens ?? 0,
|
||||
quota: byQuota.get(p),
|
||||
})
|
||||
}
|
||||
|
||||
// 排序:按 PLATFORM_ORDER,未知平台按名称排序
|
||||
cards.sort((a, b) => {
|
||||
const ai = PLATFORM_ORDER.indexOf(a.platform)
|
||||
const bi = PLATFORM_ORDER.indexOf(b.platform)
|
||||
if (ai === -1 && bi === -1) return a.platform.localeCompare(b.platform)
|
||||
if (ai === -1) return 1
|
||||
if (bi === -1) return -1
|
||||
return ai - bi
|
||||
})
|
||||
|
||||
// __other__ 补差逻辑:只对 by_platform 有 usage 数据的总和计算
|
||||
const total = props.stats?.total_actual_cost ?? 0
|
||||
const today = props.stats?.today_actual_cost ?? 0
|
||||
const sumTotal = cards.reduce((s, c) => s + c.total_actual_cost, 0)
|
||||
@@ -237,12 +318,62 @@ const platformCards = computed(() => {
|
||||
today_actual_cost: diffToday,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
isOther: true
|
||||
isOther: true,
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
})
|
||||
|
||||
// Quota helpers
|
||||
|
||||
type QuotaWindow = 'daily' | 'weekly' | 'monthly'
|
||||
type QuotaField = `${QuotaWindow}_limit_usd` | `${QuotaWindow}_usage_usd` | `${QuotaWindow}_window_resets_at`
|
||||
|
||||
function quotaVal(q: PlatformQuotaItem | undefined, key: QuotaField): PlatformQuotaItem[QuotaField] {
|
||||
return q?.[key]
|
||||
}
|
||||
|
||||
function hasAnyLimit(q: PlatformQuotaItem | undefined): boolean {
|
||||
if (!q) return false
|
||||
return q.daily_limit_usd != null || q.weekly_limit_usd != null || q.monthly_limit_usd != null
|
||||
}
|
||||
|
||||
function calcPercent(usage: number, limit: number): number {
|
||||
if (!limit || limit <= 0) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((usage / limit) * 100)))
|
||||
}
|
||||
|
||||
function quotaBarClass(p: number): string {
|
||||
if (p >= 95) return 'bg-red-500'
|
||||
if (p >= 75) return 'bg-amber-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// 与 formatBalance 一致使用 Intl.NumberFormat 做半偶舍入,避免 toFixed 在不同 JS 引擎
|
||||
// 下偶发截断而非四舍五入(与后端展示精度不一致)。
|
||||
const usdFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
function formatUsd(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0.00'
|
||||
return usdFormatter.format(n)
|
||||
}
|
||||
|
||||
function formatResetTime(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
const formatBalance = (b: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
|
||||
@@ -634,6 +634,15 @@ export default {
|
||||
platformBreakdownEmpty: 'No platform usage yet',
|
||||
platformCount: '{count} platforms',
|
||||
platformOther: 'Other',
|
||||
platformQuota: {
|
||||
title: 'Quota Usage',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly (30-day rolling)',
|
||||
resetsAt: 'Resets {time}',
|
||||
noLimit: 'unlimited',
|
||||
disabled: 'Disabled',
|
||||
},
|
||||
tokenUsageTrend: 'Token Usage Trend',
|
||||
noDataAvailable: 'No data available',
|
||||
model: 'Model',
|
||||
@@ -1793,6 +1802,7 @@ export default {
|
||||
groups: 'Groups',
|
||||
subscriptions: 'Subscriptions',
|
||||
balance: 'Balance',
|
||||
balancePlatformQuota: 'Balance (Platform Quota)',
|
||||
usage: 'Usage',
|
||||
usageAnthropic: 'Usage (Claude)',
|
||||
usageOpenAI: 'Usage (OpenAI)',
|
||||
@@ -1985,6 +1995,41 @@ export default {
|
||||
failedToReorder: 'Failed to update order',
|
||||
keyExists: 'Attribute key already exists',
|
||||
dragToReorder: 'Drag to reorder'
|
||||
},
|
||||
platformQuota: {
|
||||
menuItem: 'Platform Quotas',
|
||||
title: 'Platform Quotas',
|
||||
subtitle: 'Configure daily / weekly / monthly USD usage limits for each upstream platform for user {email}',
|
||||
columns: {
|
||||
platform: 'Platform',
|
||||
daily: 'Daily (USD)',
|
||||
weekly: 'Weekly (USD)',
|
||||
monthly: 'Monthly (USD, 30-day rolling)',
|
||||
usage: 'Current Usage',
|
||||
},
|
||||
placeholder: 'unlimited',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
cancel: 'Cancel',
|
||||
clearAll: 'Clear All (remove all limits)',
|
||||
clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.',
|
||||
reset: {
|
||||
button: 'Reset window',
|
||||
confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.',
|
||||
success: 'Reset {platform} {window} usage',
|
||||
failed: 'Reset failed',
|
||||
},
|
||||
updateSuccess: 'Platform quotas updated',
|
||||
updateFailed: 'Save failed',
|
||||
loadFailed: 'Load failed',
|
||||
hint: 'Empty = no limit for that window.',
|
||||
windowDaily: 'daily',
|
||||
windowWeekly: 'weekly',
|
||||
windowMonthly: 'monthly',
|
||||
cellNotConfigured: 'Not configured',
|
||||
cellColumnTooltip: 'Only platforms with a limit are shown',
|
||||
subscriptionWarning: 'This user has an active subscription. Platform quotas only apply to balance (standard) mode requests; subscription mode requests are not subject to these limits.',
|
||||
invalidNumber: 'The following fields contain invalid numbers. Please fix them before saving: {fields}',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5473,7 +5518,17 @@ export default {
|
||||
defaultSubscriptionsDuplicate:
|
||||
'Duplicate subscription group: {groupId}. Each group can only appear once.',
|
||||
subscriptionGroup: 'Subscription Group',
|
||||
subscriptionValidityDays: 'Validity (days)'
|
||||
subscriptionValidityDays: 'Validity (days)',
|
||||
defaultPlatformQuotas: 'Default Platform Quotas (on signup)',
|
||||
defaultPlatformQuotasHint: 'Automatically assigned to new users on signup; existing users are not affected. Leave blank = unlimited.',
|
||||
platformQuotaNotice: 'Monthly quota uses a 30-day rolling window, not a calendar month.',
|
||||
},
|
||||
platformQuota: {
|
||||
platform: 'Platform',
|
||||
daily: 'Daily (USD)',
|
||||
weekly: 'Weekly (USD)',
|
||||
monthly: 'Monthly (USD, 30d rolling)',
|
||||
placeholder: 'Unlimited',
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code Settings',
|
||||
@@ -6194,7 +6249,9 @@ export default {
|
||||
grantOnFirstBindHint: 'Grant default entitlements when an existing user first binds this source.',
|
||||
defaultSubscriptionsLabel: 'Default subscriptions',
|
||||
defaultSubscriptionsHint: 'Applies only to this auth source. Leave empty to skip source-specific subscriptions.',
|
||||
noSourceSubscriptions: 'No source-specific default subscriptions configured.'
|
||||
noSourceSubscriptions: 'No source-specific default subscriptions configured.',
|
||||
platformQuotasOverride: 'Platform Quota Overrides',
|
||||
platformQuotasOverrideHint: 'Blank fields inherit the system default. Set to 0 to fully block that window for this auth source.',
|
||||
},
|
||||
paymentVisibleMethods: {
|
||||
methodLabel: '{title} visible method',
|
||||
|
||||
@@ -633,6 +633,15 @@ export default {
|
||||
platformBreakdownEmpty: '暂无平台用量',
|
||||
platformCount: '{count} 个平台',
|
||||
platformOther: '其他',
|
||||
platformQuota: {
|
||||
title: '配额用量',
|
||||
daily: '日',
|
||||
weekly: '周',
|
||||
monthly: '月(近30天)',
|
||||
resetsAt: '{time} 重置',
|
||||
noLimit: '不限制',
|
||||
disabled: '已禁用',
|
||||
},
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
@@ -1814,6 +1823,7 @@ export default {
|
||||
groups: '分组',
|
||||
subscriptions: '订阅分组',
|
||||
balance: '余额',
|
||||
balancePlatformQuota: '余额(平台配额)',
|
||||
usage: '用量',
|
||||
usageAnthropic: '用量 (Claude)',
|
||||
usageOpenAI: '用量 (OpenAI)',
|
||||
@@ -2038,6 +2048,41 @@ export default {
|
||||
failedToReorder: '更新排序失败',
|
||||
keyExists: '属性键已存在',
|
||||
dragToReorder: '拖拽排序'
|
||||
},
|
||||
platformQuota: {
|
||||
menuItem: '平台限额',
|
||||
title: '平台限额',
|
||||
subtitle: '为用户 {email} 配置各上游平台的日 / 周 / 月用量上限',
|
||||
columns: {
|
||||
platform: '平台',
|
||||
daily: '日 (USD)',
|
||||
weekly: '周 (USD)',
|
||||
monthly: '月 (USD, 30天滚动)',
|
||||
usage: '当前用量',
|
||||
},
|
||||
placeholder: '不限制',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
cancel: '取消',
|
||||
clearAll: '全部清空(取消所有限额)',
|
||||
clearAllConfirm: '确认清空全部平台的日 / 周 / 月限额?所有平台将变为"无限额",本地无法撤销,需要在保存前手动重填。',
|
||||
reset: {
|
||||
button: '重置该窗口',
|
||||
confirm: '确认重置该用户 {platform} 平台的 {window} 用量?此操作立即生效。',
|
||||
success: '已重置 {platform} {window} 用量',
|
||||
failed: '重置失败',
|
||||
},
|
||||
updateSuccess: '平台限额已更新',
|
||||
updateFailed: '保存失败',
|
||||
loadFailed: '加载失败',
|
||||
hint: '留空 = 不限制该窗口。',
|
||||
windowDaily: '日',
|
||||
windowWeekly: '周',
|
||||
windowMonthly: '月',
|
||||
cellNotConfigured: '未配置',
|
||||
cellColumnTooltip: '仅展示已设限额的平台',
|
||||
subscriptionWarning: '此用户有活跃订阅,平台限额仅在余额(标准)模式下生效,订阅模式请求不受此限额约束。',
|
||||
invalidNumber: '以下字段填写不是合法数字,请修正后再保存:{fields}',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5632,7 +5677,17 @@ export default {
|
||||
defaultSubscriptionsEmpty: '未配置默认订阅。新用户不会自动获得订阅套餐。',
|
||||
defaultSubscriptionsDuplicate: '默认订阅存在重复分组:{groupId}。每个分组只能出现一次。',
|
||||
subscriptionGroup: '订阅分组',
|
||||
subscriptionValidityDays: '有效期(天)'
|
||||
subscriptionValidityDays: '有效期(天)',
|
||||
defaultPlatformQuotas: '默认平台限额(注册时分配)',
|
||||
defaultPlatformQuotasHint: '新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。',
|
||||
platformQuotaNotice: '月限额为 30 天滚动窗口,非自然月',
|
||||
},
|
||||
platformQuota: {
|
||||
platform: '平台',
|
||||
daily: '日限额 (USD)',
|
||||
weekly: '周限额 (USD)',
|
||||
monthly: '月限额 (USD, 30天滚动)',
|
||||
placeholder: '不限',
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 设置',
|
||||
@@ -6352,7 +6407,9 @@ export default {
|
||||
grantOnFirstBindHint: '已有账号首次绑定该来源时发放默认权益。',
|
||||
defaultSubscriptionsLabel: '默认订阅',
|
||||
defaultSubscriptionsHint: '仅对当前认证来源生效,未配置时不追加来源专属订阅。',
|
||||
noSourceSubscriptions: '当前来源未配置专属默认订阅。'
|
||||
noSourceSubscriptions: '当前来源未配置专属默认订阅。',
|
||||
platformQuotasOverride: '平台限额覆盖',
|
||||
platformQuotasOverrideHint: '留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。',
|
||||
},
|
||||
paymentVisibleMethods: {
|
||||
methodLabel: '{title} 可见方式',
|
||||
|
||||
@@ -1854,3 +1854,11 @@ export interface UpdateScheduledTestPlanRequest {
|
||||
|
||||
// Payment types
|
||||
export type { SubscriptionPlan, PaymentOrder, CheckoutInfoResponse } from './payment'
|
||||
|
||||
export type {
|
||||
PlatformQuotaItem,
|
||||
PlatformQuotaUpdateItem,
|
||||
PlatformQuotaPlatform,
|
||||
PlatformQuotaWindow,
|
||||
PlatformQuotasResponse,
|
||||
} from '@/api/admin/users'
|
||||
|
||||
@@ -3262,6 +3262,71 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 新增:系统全局默认平台限额矩阵 -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.defaults.defaultPlatformQuotas") }}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.defaults.defaultPlatformQuotasHint") }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("admin.settings.defaults.platformQuotaNotice") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.platform") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.daily") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.weekly") }}</th>
|
||||
<th class="pb-2 font-medium">{{ t("admin.settings.platformQuota.monthly") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="space-y-2">
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="p" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.daily"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.weekly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.monthly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /全局平台限额矩阵 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3535,6 +3600,68 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 新增:auth source 平台限额覆盖区块 -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.authSourceDefaults.platformQuotasOverride") }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.authSourceDefaults.platformQuotasOverrideHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.platform") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.daily") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.weekly") }}</th>
|
||||
<th class="pb-2 font-medium">{{ t("admin.settings.platformQuota.monthly") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.daily"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.weekly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.monthly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /auth source 平台限额覆盖区块 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6530,6 +6657,8 @@ import { adminAPI } from "@/api";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
@@ -6541,6 +6670,7 @@ import type {
|
||||
SystemSettings,
|
||||
UpdateSettingsRequest,
|
||||
DefaultSubscriptionSetting,
|
||||
DefaultPlatformQuotasMap,
|
||||
OpenAIFastPolicyRule,
|
||||
WeChatConnectMode,
|
||||
WebSearchEmulationConfig,
|
||||
@@ -6835,6 +6965,8 @@ type SettingsForm = Omit<
|
||||
google_oauth_client_secret: string;
|
||||
force_email_on_third_party_signup: boolean;
|
||||
openai_advanced_scheduler_enabled: boolean;
|
||||
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
|
||||
default_platform_quotas: DefaultPlatformQuotasMap;
|
||||
};
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
@@ -6851,6 +6983,7 @@ const form = reactive<SettingsForm>({
|
||||
login_agreement_updated_at: "2026-03-31",
|
||||
login_agreement_documents: defaultLoginAgreementDocuments(),
|
||||
default_balance: 0,
|
||||
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
|
||||
affiliate_rebate_rate: 20,
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
@@ -7659,6 +7792,7 @@ async function loadSettings() {
|
||||
}))
|
||||
: defaultLoginAgreementDocuments();
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
@@ -8212,6 +8346,7 @@ async function saveSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload);
|
||||
@@ -8222,6 +8357,7 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
|
||||
@@ -420,6 +420,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-balance_platform_quota="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="block text-left underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:decoration-primary-400 dark:decoration-dark-500"
|
||||
:title="t('admin.users.platformQuota.cellColumnTooltip')"
|
||||
@click="handlePlatformQuota(row)"
|
||||
>
|
||||
<UserPlatformQuotaCell :quotas="platformQuotaStats[row.id]" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 用量列自定义表头:列名 + 单个排序图标按钮,点击展开"今日/近30天"菜单。
|
||||
column.sortable=false,DataTable 内置点击逻辑不会触发;
|
||||
菜单项三态循环:desc → asc → off。 -->
|
||||
@@ -673,6 +684,15 @@
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
|
||||
<!-- Platform Quotas -->
|
||||
<button
|
||||
@click="handlePlatformQuota(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="chartBar" size="sm" class="text-gray-400" :stroke-width="2" />
|
||||
{{ t('admin.users.platformQuota.menuItem') }}
|
||||
</button>
|
||||
|
||||
<!-- Balance History -->
|
||||
<button
|
||||
@click="handleBalanceHistory(user); closeActionMenu()"
|
||||
@@ -702,6 +722,12 @@
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
|
||||
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
|
||||
<UserPlatformQuotaModal
|
||||
:show="showPlatformQuotaModal"
|
||||
:user="platformQuotaUser"
|
||||
@close="closePlatformQuotaModal"
|
||||
@success="loadUsers"
|
||||
/>
|
||||
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
|
||||
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
|
||||
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
|
||||
@@ -723,6 +749,7 @@ const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { AdminUser, AdminGroup, UserAttributeDefinition } from '@/types'
|
||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||
import type { PlatformQuotaItem } from '@/api/admin/users'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -736,8 +763,10 @@ import UserAttributesConfigModal from '@/components/user/UserAttributesConfigMod
|
||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
|
||||
import PlatformUsageBreakdown from '@/components/user/PlatformUsageBreakdown.vue'
|
||||
import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
|
||||
import UserPlatformQuotaCell from '@/components/user/UserPlatformQuotaCell.vue'
|
||||
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserPlatformQuotaModal from '@/components/admin/user/UserPlatformQuotaModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
|
||||
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
|
||||
@@ -804,6 +833,7 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'groups', label: t('admin.users.columns.groups'), sortable: false },
|
||||
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||
{ key: 'balance_platform_quota', label: t('admin.users.columns.balancePlatformQuota'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||
{ key: 'usage_anthropic', label: t('admin.users.columns.usageAnthropic'), sortable: false },
|
||||
{ key: 'usage_openai', label: t('admin.users.columns.usageOpenAI'), sortable: false },
|
||||
@@ -829,7 +859,8 @@ const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
// Default hidden columns (columns hidden by default on first load)
|
||||
const DEFAULT_HIDDEN_COLUMNS = [
|
||||
'notes', 'groups', 'subscriptions', 'usage', 'concurrency',
|
||||
'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity'
|
||||
'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity',
|
||||
'balance_platform_quota'
|
||||
]
|
||||
const REMOVED_COLUMNS = new Set(['last_login_at'])
|
||||
// 强制可见列:加载时会被强制移出 hiddenColumns,并在列设置 UI 上 disabled。
|
||||
@@ -842,9 +873,10 @@ const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
|
||||
// 并在 VERSION_NEW_HIDDEN_COLUMNS 中登记该版本新增的 key。
|
||||
// 这样老用户升级后这些新列会被自动隐藏一次,而不会影响他们对其它老列的偏好。
|
||||
const COLUMN_SETTINGS_VERSION_KEY = 'user-column-settings-version'
|
||||
const COLUMN_SETTINGS_VERSION = 2
|
||||
const COLUMN_SETTINGS_VERSION = 3
|
||||
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
|
||||
2: ['usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity']
|
||||
2: ['usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity'],
|
||||
3: ['balance_platform_quota']
|
||||
}
|
||||
|
||||
// Load saved column settings
|
||||
@@ -907,7 +939,7 @@ const toggleColumn = (key: string) => {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_'))) {
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_') || key === 'balance_platform_quota')) {
|
||||
refreshCurrentPageSecondaryData()
|
||||
}
|
||||
if (key === 'subscriptions') {
|
||||
@@ -936,8 +968,8 @@ const PLATFORM_USAGE_COLUMNS = USAGE_COLUMN_KEYS.filter((k) => k !== 'usage')
|
||||
const hasVisibleUsageColumn = computed(
|
||||
() => !hiddenColumns.has('usage') || PLATFORM_USAGE_COLUMNS.some((k) => !hiddenColumns.has(k))
|
||||
)
|
||||
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
|
||||
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
|
||||
const hasVisiblePlatformQuotaColumn = computed(() => !hiddenColumns.has('balance_platform_quota'))
|
||||
const hasVisibleAttributeColumns = computed(() =>
|
||||
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
|
||||
)
|
||||
@@ -1095,6 +1127,7 @@ const getAttributeDefinition = (attrId: number): UserAttributeDefinition | undef
|
||||
return attributeDefinitions.value.find(d => d.id === attrId)
|
||||
}
|
||||
const usageStats = ref<Record<string, BatchUserUsageStats>>({})
|
||||
const platformQuotaStats = ref<Record<number, PlatformQuotaItem[]>>({})
|
||||
|
||||
const getPlatformUsage = (userId: number, platform: string) =>
|
||||
usageStats.value[userId]?.by_platform?.find((p) => p.platform === platform)
|
||||
@@ -1201,9 +1234,21 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const showPlatformQuotaModal = ref(false)
|
||||
const editingUser = ref<AdminUser | null>(null)
|
||||
const deletingUser = ref<AdminUser | null>(null)
|
||||
const viewingUser = ref<AdminUser | null>(null)
|
||||
const platformQuotaUser = ref<AdminUser | null>(null)
|
||||
|
||||
const handlePlatformQuota = (user: AdminUser) => {
|
||||
platformQuotaUser.value = user
|
||||
showPlatformQuotaModal.value = true
|
||||
}
|
||||
|
||||
const closePlatformQuotaModal = () => {
|
||||
showPlatformQuotaModal.value = false
|
||||
platformQuotaUser.value = null
|
||||
}
|
||||
let abortController: AbortController | null = null
|
||||
let secondaryDataSeq = 0
|
||||
|
||||
@@ -1248,6 +1293,37 @@ const loadUsersSecondaryData = async (
|
||||
)
|
||||
}
|
||||
|
||||
if (hasVisiblePlatformQuotaColumn.value) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
// 无批量端点:对当前页用户逐个拉取,分块并发(每批 6),批间检查中止条件,避免大 pageSize 时请求洪峰
|
||||
const CHUNK = 6
|
||||
for (let i = 0; i < userIds.length; i += CHUNK) {
|
||||
if (signal?.aborted) return
|
||||
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
|
||||
const chunk = userIds.slice(i, i + CHUNK)
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map((id) => adminAPI.users.getPlatformQuotas(id))
|
||||
)
|
||||
if (signal?.aborted) return
|
||||
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
|
||||
const merged = { ...platformQuotaStats.value }
|
||||
results.forEach((r, idx) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
merged[chunk[idx]] = r.value.platform_quotas || []
|
||||
}
|
||||
})
|
||||
platformQuotaStats.value = merged
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return
|
||||
console.error('Failed to load platform quotas:', e)
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.allSettled(tasks)
|
||||
}
|
||||
@@ -1417,7 +1493,8 @@ const loadUsers = async () => {
|
||||
search: searchQuery.value || undefined,
|
||||
group_name: filters.group || undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value,
|
||||
// 始终请求 subscriptions:列隐藏时仍需用于 UserPlatformQuotaModal 的 active-subscription 警示 banner
|
||||
include_subscriptions: true,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
@@ -1431,6 +1508,7 @@ const loadUsers = async () => {
|
||||
pagination.pages = response.pages
|
||||
usageStats.value = {}
|
||||
userAttributeValues.value = {}
|
||||
platformQuotaStats.value = {}
|
||||
|
||||
// Defer heavy secondary data so table can render first.
|
||||
if (response.items.length > 0) {
|
||||
|
||||
@@ -163,6 +163,16 @@ vi.mock("vue-i18n", async () => {
|
||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||
"admin.settings.site.uploadImage": "上传图片",
|
||||
"admin.settings.site.remove": "移除",
|
||||
"admin.settings.platformQuota.platform": "平台",
|
||||
"admin.settings.platformQuota.daily": "日限额 (USD)",
|
||||
"admin.settings.platformQuota.weekly": "周限额 (USD)",
|
||||
"admin.settings.platformQuota.monthly": "月限额 (USD, 30天滚动)",
|
||||
"admin.settings.platformQuota.placeholder": "不限",
|
||||
"admin.settings.defaults.defaultPlatformQuotas": "默认平台限额(注册时分配)",
|
||||
"admin.settings.defaults.defaultPlatformQuotasHint": "新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。",
|
||||
"admin.settings.defaults.platformQuotaNotice": "月限额为 30 天滚动窗口,非自然月",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverride": "平台限额覆盖",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverrideHint": "留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
@@ -403,6 +413,13 @@ const baseSettingsResponse = {
|
||||
subscription_expiry_notify_enabled: true,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [],
|
||||
// 平台限额嵌套字段(新后端契约)
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: 200 },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
};
|
||||
|
||||
function mountView() {
|
||||
@@ -984,3 +1001,157 @@ describe("admin SettingsView wechat connect controls", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin SettingsView platform quota matrix", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset();
|
||||
updateSettings.mockReset();
|
||||
getWebSearchEmulationConfig.mockReset();
|
||||
updateWebSearchEmulationConfig.mockReset();
|
||||
getAdminApiKey.mockReset();
|
||||
getOverloadCooldownSettings.mockReset();
|
||||
getRateLimit429CooldownSettings.mockReset();
|
||||
updateRateLimit429CooldownSettings.mockReset();
|
||||
getStreamTimeoutSettings.mockReset();
|
||||
getRectifierSettings.mockReset();
|
||||
getBetaPolicySettings.mockReset();
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
updateProvider.mockReset();
|
||||
createProvider.mockReset();
|
||||
deleteProvider.mockReset();
|
||||
fetchPublicSettings.mockReset();
|
||||
adminSettingsFetch.mockReset();
|
||||
showError.mockReset();
|
||||
showSuccess.mockReset();
|
||||
localeRef.value = "zh-CN";
|
||||
|
||||
getSettings.mockResolvedValue({ ...baseSettingsResponse });
|
||||
updateSettings.mockImplementation(async (payload) => ({
|
||||
...baseSettingsResponse,
|
||||
...payload,
|
||||
}));
|
||||
getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "" });
|
||||
getOverloadCooldownSettings.mockResolvedValue({});
|
||||
getRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
updateRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
getStreamTimeoutSettings.mockResolvedValue({});
|
||||
getRectifierSettings.mockResolvedValue({});
|
||||
getBetaPolicySettings.mockResolvedValue({});
|
||||
getGroups.mockResolvedValue([]);
|
||||
listProxies.mockResolvedValue({ items: [] });
|
||||
getProviders.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 4 平台行", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
|
||||
const html = wrapper.html();
|
||||
// 表格行的平台字段:font-mono 渲染纯英文 platform key
|
||||
expect(html).toContain("anthropic");
|
||||
expect(html).toContain("openai");
|
||||
expect(html).toContain("gemini");
|
||||
expect(html).toContain("antigravity");
|
||||
});
|
||||
|
||||
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 4 平台)", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
const lastCallArgs = updateSettings.mock.calls.at(-1);
|
||||
expect(lastCallArgs).toBeDefined();
|
||||
const payload = lastCallArgs![0] as Record<string, unknown>;
|
||||
|
||||
// 应携带嵌套对象,而非扁平字段
|
||||
expect(payload).toHaveProperty("default_platform_quotas");
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
|
||||
const platforms = ["anthropic", "openai", "gemini", "antigravity"];
|
||||
for (const p of platforms) {
|
||||
expect(quotas).toHaveProperty(p);
|
||||
const pq = quotas[p] as Record<string, unknown>;
|
||||
expect(pq).toHaveProperty("daily");
|
||||
expect(pq).toHaveProperty("weekly");
|
||||
expect(pq).toHaveProperty("monthly");
|
||||
}
|
||||
|
||||
// 不应存在旧扁平字段
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_anthropic_daily");
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
|
||||
});
|
||||
|
||||
it("加载后 form.default_platform_quotas 含全 4 平台,从嵌套 JSON 正确读取数值", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 5, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
// gemini / antigravity 缺失 → 应被归一化为全 null
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(5);
|
||||
expect(quotas["openai"]?.["weekly"]).toBe(12.5);
|
||||
// 缺失平台应补全为 null
|
||||
expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => {
|
||||
// 模拟后端返回带有 anthropic daily 值的配额
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
// 找到 anthropic daily 输入框并清空(模拟用户删除值)
|
||||
const inputs = wrapper.findAll('input[type="number"]');
|
||||
const anthropicDailyInput = inputs.find((i) => {
|
||||
const parent = i.element.closest("tr");
|
||||
return parent?.textContent?.includes("anthropic");
|
||||
});
|
||||
|
||||
if (anthropicDailyInput) {
|
||||
// 设置为空字符串,模拟 v-model.number 在清空时产出 ""
|
||||
await anthropicDailyInput.setValue("");
|
||||
}
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
// 不管输入是什么,提交值应为 null(而非 "" 或 NaN)
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12"><LoadingSpinner /></div>
|
||||
<template v-else-if="stats">
|
||||
<UserDashboardStats :stats="stats" :balance="user?.balance || 0" :is-simple="authStore.isSimpleMode" />
|
||||
<UserDashboardStats :stats="stats" :balance="user?.balance || 0" :is-simple="authStore.isSimpleMode" :platform-quotas="platformQuotas" />
|
||||
<UserDashboardCharts v-model:startDate="startDate" v-model:endDate="endDate" v-model:granularity="granularity" :loading="loadingCharts" :trend="trendData" :models="modelStats" @dateRangeChange="loadCharts" @granularityChange="loadCharts" @refresh="refreshAll" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2"><UserDashboardRecentUsage :data="recentUsage" :loading="loadingUsage" /></div>
|
||||
@@ -19,11 +19,13 @@ import { ref, computed, onMounted } from 'vue'; import { useAuthStore } from '@/
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import UserDashboardStats from '@/components/user/dashboard/UserDashboardStats.vue'; import UserDashboardCharts from '@/components/user/dashboard/UserDashboardCharts.vue'
|
||||
import UserDashboardRecentUsage from '@/components/user/dashboard/UserDashboardRecentUsage.vue'; import UserDashboardQuickActions from '@/components/user/dashboard/UserDashboardQuickActions.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat, PlatformQuotaItem } from '@/types'
|
||||
import { getMyPlatformQuotas } from '@/api/user'
|
||||
|
||||
const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
const stats = ref<UserStatsType | null>(null); const loading = ref(false); const loadingUsage = ref(false); const loadingCharts = ref(false)
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const recentUsage = ref<UsageLog[]>([])
|
||||
const platformQuotas = ref<PlatformQuotaItem[] | null>(null)
|
||||
|
||||
const formatLD = (d: Date) => d.toISOString().split('T')[0]
|
||||
const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day')
|
||||
@@ -31,7 +33,8 @@ const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endD
|
||||
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
|
||||
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }
|
||||
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } }
|
||||
const refreshAll = () => { loadStats(); loadCharts(); loadRecent() }
|
||||
const loadPlatformQuotas = async () => { try { const data = await getMyPlatformQuotas(); platformQuotas.value = data.platform_quotas ?? [] } catch (error) { console.warn('Failed to load platform quotas:', error); platformQuotas.value = [] } }
|
||||
const refreshAll = () => { loadStats(); loadCharts(); loadRecent(); loadPlatformQuotas() }
|
||||
|
||||
onMounted(() => { refreshAll() })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user