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
+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>