Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota

feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
Wesley Liddick
2026-05-26 14:13:18 +08:00
committed by GitHub
123 changed files with 14220 additions and 232 deletions
@@ -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 });
}
});
});
+63
View File
@@ -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;
+76 -1
View File
@@ -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
+12 -2
View File
@@ -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,
+59 -2
View File
@@ -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',
+59 -2
View File
@@ -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} 可见方式',
+8
View File
@@ -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'
+136
View File
@@ -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,
+84 -6
View File
@@ -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);
});
});
+6 -3
View File
@@ -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>