mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota
feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
@@ -3262,6 +3262,71 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 新增:系统全局默认平台限额矩阵 -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.defaults.defaultPlatformQuotas") }}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.defaults.defaultPlatformQuotasHint") }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("admin.settings.defaults.platformQuotaNotice") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.platform") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.daily") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.weekly") }}</th>
|
||||
<th class="pb-2 font-medium">{{ t("admin.settings.platformQuota.monthly") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="space-y-2">
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="p" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.daily"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.weekly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<input
|
||||
v-model.number="form.default_platform_quotas[p]!.monthly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /全局平台限额矩阵 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3535,6 +3600,68 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 新增:auth source 平台限额覆盖区块 -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3">
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.authSourceDefaults.platformQuotasOverride") }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.authSourceDefaults.platformQuotasOverrideHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.platform") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.daily") }}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{{ t("admin.settings.platformQuota.weekly") }}</th>
|
||||
<th class="pb-2 font-medium">{{ t("admin.settings.platformQuota.monthly") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
|
||||
<td class="pr-4 py-1">
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.daily"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="pr-4 py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.weekly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].platform_quotas[p]!.monthly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input h-8 w-28 text-sm"
|
||||
:placeholder="t('admin.settings.platformQuota.placeholder')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /auth source 平台限额覆盖区块 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6530,6 +6657,8 @@ import { adminAPI } from "@/api";
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
@@ -6541,6 +6670,7 @@ import type {
|
||||
SystemSettings,
|
||||
UpdateSettingsRequest,
|
||||
DefaultSubscriptionSetting,
|
||||
DefaultPlatformQuotasMap,
|
||||
OpenAIFastPolicyRule,
|
||||
WeChatConnectMode,
|
||||
WebSearchEmulationConfig,
|
||||
@@ -6835,6 +6965,8 @@ type SettingsForm = Omit<
|
||||
google_oauth_client_secret: string;
|
||||
force_email_on_third_party_signup: boolean;
|
||||
openai_advanced_scheduler_enabled: boolean;
|
||||
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
|
||||
default_platform_quotas: DefaultPlatformQuotasMap;
|
||||
};
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
@@ -6851,6 +6983,7 @@ const form = reactive<SettingsForm>({
|
||||
login_agreement_updated_at: "2026-03-31",
|
||||
login_agreement_documents: defaultLoginAgreementDocuments(),
|
||||
default_balance: 0,
|
||||
default_platform_quotas: normalizePlatformQuotasMap() as DefaultPlatformQuotasMap,
|
||||
affiliate_rebate_rate: 20,
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
@@ -7659,6 +7792,7 @@ async function loadSettings() {
|
||||
}))
|
||||
: defaultLoginAgreementDocuments();
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas);
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
@@ -8212,6 +8346,7 @@ async function saveSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload);
|
||||
@@ -8222,6 +8357,7 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
|
||||
@@ -420,6 +420,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-balance_platform_quota="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="block text-left underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:decoration-primary-400 dark:decoration-dark-500"
|
||||
:title="t('admin.users.platformQuota.cellColumnTooltip')"
|
||||
@click="handlePlatformQuota(row)"
|
||||
>
|
||||
<UserPlatformQuotaCell :quotas="platformQuotaStats[row.id]" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 用量列自定义表头:列名 + 单个排序图标按钮,点击展开"今日/近30天"菜单。
|
||||
column.sortable=false,DataTable 内置点击逻辑不会触发;
|
||||
菜单项三态循环:desc → asc → off。 -->
|
||||
@@ -673,6 +684,15 @@
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
|
||||
<!-- Platform Quotas -->
|
||||
<button
|
||||
@click="handlePlatformQuota(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="chartBar" size="sm" class="text-gray-400" :stroke-width="2" />
|
||||
{{ t('admin.users.platformQuota.menuItem') }}
|
||||
</button>
|
||||
|
||||
<!-- Balance History -->
|
||||
<button
|
||||
@click="handleBalanceHistory(user); closeActionMenu()"
|
||||
@@ -702,6 +722,12 @@
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
|
||||
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
|
||||
<UserPlatformQuotaModal
|
||||
:show="showPlatformQuotaModal"
|
||||
:user="platformQuotaUser"
|
||||
@close="closePlatformQuotaModal"
|
||||
@success="loadUsers"
|
||||
/>
|
||||
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
|
||||
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
|
||||
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
|
||||
@@ -723,6 +749,7 @@ const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { AdminUser, AdminGroup, UserAttributeDefinition } from '@/types'
|
||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||
import type { PlatformQuotaItem } from '@/api/admin/users'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -736,8 +763,10 @@ import UserAttributesConfigModal from '@/components/user/UserAttributesConfigMod
|
||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
|
||||
import PlatformUsageBreakdown from '@/components/user/PlatformUsageBreakdown.vue'
|
||||
import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
|
||||
import UserPlatformQuotaCell from '@/components/user/UserPlatformQuotaCell.vue'
|
||||
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserPlatformQuotaModal from '@/components/admin/user/UserPlatformQuotaModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
|
||||
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
|
||||
@@ -804,6 +833,7 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'groups', label: t('admin.users.columns.groups'), sortable: false },
|
||||
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||
{ key: 'balance_platform_quota', label: t('admin.users.columns.balancePlatformQuota'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||
{ key: 'usage_anthropic', label: t('admin.users.columns.usageAnthropic'), sortable: false },
|
||||
{ key: 'usage_openai', label: t('admin.users.columns.usageOpenAI'), sortable: false },
|
||||
@@ -829,7 +859,8 @@ const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
// Default hidden columns (columns hidden by default on first load)
|
||||
const DEFAULT_HIDDEN_COLUMNS = [
|
||||
'notes', 'groups', 'subscriptions', 'usage', 'concurrency',
|
||||
'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity'
|
||||
'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity',
|
||||
'balance_platform_quota'
|
||||
]
|
||||
const REMOVED_COLUMNS = new Set(['last_login_at'])
|
||||
// 强制可见列:加载时会被强制移出 hiddenColumns,并在列设置 UI 上 disabled。
|
||||
@@ -842,9 +873,10 @@ const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
|
||||
// 并在 VERSION_NEW_HIDDEN_COLUMNS 中登记该版本新增的 key。
|
||||
// 这样老用户升级后这些新列会被自动隐藏一次,而不会影响他们对其它老列的偏好。
|
||||
const COLUMN_SETTINGS_VERSION_KEY = 'user-column-settings-version'
|
||||
const COLUMN_SETTINGS_VERSION = 2
|
||||
const COLUMN_SETTINGS_VERSION = 3
|
||||
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
|
||||
2: ['usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity']
|
||||
2: ['usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity'],
|
||||
3: ['balance_platform_quota']
|
||||
}
|
||||
|
||||
// Load saved column settings
|
||||
@@ -907,7 +939,7 @@ const toggleColumn = (key: string) => {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_'))) {
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_') || key === 'balance_platform_quota')) {
|
||||
refreshCurrentPageSecondaryData()
|
||||
}
|
||||
if (key === 'subscriptions') {
|
||||
@@ -936,8 +968,8 @@ const PLATFORM_USAGE_COLUMNS = USAGE_COLUMN_KEYS.filter((k) => k !== 'usage')
|
||||
const hasVisibleUsageColumn = computed(
|
||||
() => !hiddenColumns.has('usage') || PLATFORM_USAGE_COLUMNS.some((k) => !hiddenColumns.has(k))
|
||||
)
|
||||
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
|
||||
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
|
||||
const hasVisiblePlatformQuotaColumn = computed(() => !hiddenColumns.has('balance_platform_quota'))
|
||||
const hasVisibleAttributeColumns = computed(() =>
|
||||
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
|
||||
)
|
||||
@@ -1095,6 +1127,7 @@ const getAttributeDefinition = (attrId: number): UserAttributeDefinition | undef
|
||||
return attributeDefinitions.value.find(d => d.id === attrId)
|
||||
}
|
||||
const usageStats = ref<Record<string, BatchUserUsageStats>>({})
|
||||
const platformQuotaStats = ref<Record<number, PlatformQuotaItem[]>>({})
|
||||
|
||||
const getPlatformUsage = (userId: number, platform: string) =>
|
||||
usageStats.value[userId]?.by_platform?.find((p) => p.platform === platform)
|
||||
@@ -1201,9 +1234,21 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const showPlatformQuotaModal = ref(false)
|
||||
const editingUser = ref<AdminUser | null>(null)
|
||||
const deletingUser = ref<AdminUser | null>(null)
|
||||
const viewingUser = ref<AdminUser | null>(null)
|
||||
const platformQuotaUser = ref<AdminUser | null>(null)
|
||||
|
||||
const handlePlatformQuota = (user: AdminUser) => {
|
||||
platformQuotaUser.value = user
|
||||
showPlatformQuotaModal.value = true
|
||||
}
|
||||
|
||||
const closePlatformQuotaModal = () => {
|
||||
showPlatformQuotaModal.value = false
|
||||
platformQuotaUser.value = null
|
||||
}
|
||||
let abortController: AbortController | null = null
|
||||
let secondaryDataSeq = 0
|
||||
|
||||
@@ -1248,6 +1293,37 @@ const loadUsersSecondaryData = async (
|
||||
)
|
||||
}
|
||||
|
||||
if (hasVisiblePlatformQuotaColumn.value) {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
// 无批量端点:对当前页用户逐个拉取,分块并发(每批 6),批间检查中止条件,避免大 pageSize 时请求洪峰
|
||||
const CHUNK = 6
|
||||
for (let i = 0; i < userIds.length; i += CHUNK) {
|
||||
if (signal?.aborted) return
|
||||
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
|
||||
const chunk = userIds.slice(i, i + CHUNK)
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map((id) => adminAPI.users.getPlatformQuotas(id))
|
||||
)
|
||||
if (signal?.aborted) return
|
||||
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
|
||||
const merged = { ...platformQuotaStats.value }
|
||||
results.forEach((r, idx) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
merged[chunk[idx]] = r.value.platform_quotas || []
|
||||
}
|
||||
})
|
||||
platformQuotaStats.value = merged
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return
|
||||
console.error('Failed to load platform quotas:', e)
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.allSettled(tasks)
|
||||
}
|
||||
@@ -1417,7 +1493,8 @@ const loadUsers = async () => {
|
||||
search: searchQuery.value || undefined,
|
||||
group_name: filters.group || undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value,
|
||||
// 始终请求 subscriptions:列隐藏时仍需用于 UserPlatformQuotaModal 的 active-subscription 警示 banner
|
||||
include_subscriptions: true,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
@@ -1431,6 +1508,7 @@ const loadUsers = async () => {
|
||||
pagination.pages = response.pages
|
||||
usageStats.value = {}
|
||||
userAttributeValues.value = {}
|
||||
platformQuotaStats.value = {}
|
||||
|
||||
// Defer heavy secondary data so table can render first.
|
||||
if (response.items.length > 0) {
|
||||
|
||||
@@ -163,6 +163,16 @@ vi.mock("vue-i18n", async () => {
|
||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||
"admin.settings.site.uploadImage": "上传图片",
|
||||
"admin.settings.site.remove": "移除",
|
||||
"admin.settings.platformQuota.platform": "平台",
|
||||
"admin.settings.platformQuota.daily": "日限额 (USD)",
|
||||
"admin.settings.platformQuota.weekly": "周限额 (USD)",
|
||||
"admin.settings.platformQuota.monthly": "月限额 (USD, 30天滚动)",
|
||||
"admin.settings.platformQuota.placeholder": "不限",
|
||||
"admin.settings.defaults.defaultPlatformQuotas": "默认平台限额(注册时分配)",
|
||||
"admin.settings.defaults.defaultPlatformQuotasHint": "新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。",
|
||||
"admin.settings.defaults.platformQuotaNotice": "月限额为 30 天滚动窗口,非自然月",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverride": "平台限额覆盖",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverrideHint": "留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
@@ -403,6 +413,13 @@ const baseSettingsResponse = {
|
||||
subscription_expiry_notify_enabled: true,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [],
|
||||
// 平台限额嵌套字段(新后端契约)
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: 200 },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
};
|
||||
|
||||
function mountView() {
|
||||
@@ -984,3 +1001,157 @@ describe("admin SettingsView wechat connect controls", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin SettingsView platform quota matrix", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset();
|
||||
updateSettings.mockReset();
|
||||
getWebSearchEmulationConfig.mockReset();
|
||||
updateWebSearchEmulationConfig.mockReset();
|
||||
getAdminApiKey.mockReset();
|
||||
getOverloadCooldownSettings.mockReset();
|
||||
getRateLimit429CooldownSettings.mockReset();
|
||||
updateRateLimit429CooldownSettings.mockReset();
|
||||
getStreamTimeoutSettings.mockReset();
|
||||
getRectifierSettings.mockReset();
|
||||
getBetaPolicySettings.mockReset();
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
updateProvider.mockReset();
|
||||
createProvider.mockReset();
|
||||
deleteProvider.mockReset();
|
||||
fetchPublicSettings.mockReset();
|
||||
adminSettingsFetch.mockReset();
|
||||
showError.mockReset();
|
||||
showSuccess.mockReset();
|
||||
localeRef.value = "zh-CN";
|
||||
|
||||
getSettings.mockResolvedValue({ ...baseSettingsResponse });
|
||||
updateSettings.mockImplementation(async (payload) => ({
|
||||
...baseSettingsResponse,
|
||||
...payload,
|
||||
}));
|
||||
getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "" });
|
||||
getOverloadCooldownSettings.mockResolvedValue({});
|
||||
getRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
updateRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
getStreamTimeoutSettings.mockResolvedValue({});
|
||||
getRectifierSettings.mockResolvedValue({});
|
||||
getBetaPolicySettings.mockResolvedValue({});
|
||||
getGroups.mockResolvedValue([]);
|
||||
listProxies.mockResolvedValue({ items: [] });
|
||||
getProviders.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 4 平台行", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
|
||||
const html = wrapper.html();
|
||||
// 表格行的平台字段:font-mono 渲染纯英文 platform key
|
||||
expect(html).toContain("anthropic");
|
||||
expect(html).toContain("openai");
|
||||
expect(html).toContain("gemini");
|
||||
expect(html).toContain("antigravity");
|
||||
});
|
||||
|
||||
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 4 平台)", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
const lastCallArgs = updateSettings.mock.calls.at(-1);
|
||||
expect(lastCallArgs).toBeDefined();
|
||||
const payload = lastCallArgs![0] as Record<string, unknown>;
|
||||
|
||||
// 应携带嵌套对象,而非扁平字段
|
||||
expect(payload).toHaveProperty("default_platform_quotas");
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
|
||||
const platforms = ["anthropic", "openai", "gemini", "antigravity"];
|
||||
for (const p of platforms) {
|
||||
expect(quotas).toHaveProperty(p);
|
||||
const pq = quotas[p] as Record<string, unknown>;
|
||||
expect(pq).toHaveProperty("daily");
|
||||
expect(pq).toHaveProperty("weekly");
|
||||
expect(pq).toHaveProperty("monthly");
|
||||
}
|
||||
|
||||
// 不应存在旧扁平字段
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_anthropic_daily");
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
|
||||
});
|
||||
|
||||
it("加载后 form.default_platform_quotas 含全 4 平台,从嵌套 JSON 正确读取数值", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 5, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
// gemini / antigravity 缺失 → 应被归一化为全 null
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(5);
|
||||
expect(quotas["openai"]?.["weekly"]).toBe(12.5);
|
||||
// 缺失平台应补全为 null
|
||||
expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => {
|
||||
// 模拟后端返回带有 anthropic daily 值的配额
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
// 找到 anthropic daily 输入框并清空(模拟用户删除值)
|
||||
const inputs = wrapper.findAll('input[type="number"]');
|
||||
const anthropicDailyInput = inputs.find((i) => {
|
||||
const parent = i.element.closest("tr");
|
||||
return parent?.textContent?.includes("anthropic");
|
||||
});
|
||||
|
||||
if (anthropicDailyInput) {
|
||||
// 设置为空字符串,模拟 v-model.number 在清空时产出 ""
|
||||
await anthropicDailyInput.setValue("");
|
||||
}
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
// 不管输入是什么,提交值应为 null(而非 "" 或 NaN)
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12"><LoadingSpinner /></div>
|
||||
<template v-else-if="stats">
|
||||
<UserDashboardStats :stats="stats" :balance="user?.balance || 0" :is-simple="authStore.isSimpleMode" />
|
||||
<UserDashboardStats :stats="stats" :balance="user?.balance || 0" :is-simple="authStore.isSimpleMode" :platform-quotas="platformQuotas" />
|
||||
<UserDashboardCharts v-model:startDate="startDate" v-model:endDate="endDate" v-model:granularity="granularity" :loading="loadingCharts" :trend="trendData" :models="modelStats" @dateRangeChange="loadCharts" @granularityChange="loadCharts" @refresh="refreshAll" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2"><UserDashboardRecentUsage :data="recentUsage" :loading="loadingUsage" /></div>
|
||||
@@ -19,11 +19,13 @@ import { ref, computed, onMounted } from 'vue'; import { useAuthStore } from '@/
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import UserDashboardStats from '@/components/user/dashboard/UserDashboardStats.vue'; import UserDashboardCharts from '@/components/user/dashboard/UserDashboardCharts.vue'
|
||||
import UserDashboardRecentUsage from '@/components/user/dashboard/UserDashboardRecentUsage.vue'; import UserDashboardQuickActions from '@/components/user/dashboard/UserDashboardQuickActions.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat, PlatformQuotaItem } from '@/types'
|
||||
import { getMyPlatformQuotas } from '@/api/user'
|
||||
|
||||
const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
const stats = ref<UserStatsType | null>(null); const loading = ref(false); const loadingUsage = ref(false); const loadingCharts = ref(false)
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const recentUsage = ref<UsageLog[]>([])
|
||||
const platformQuotas = ref<PlatformQuotaItem[] | null>(null)
|
||||
|
||||
const formatLD = (d: Date) => d.toISOString().split('T')[0]
|
||||
const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day')
|
||||
@@ -31,7 +33,8 @@ const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endD
|
||||
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
|
||||
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }
|
||||
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } }
|
||||
const refreshAll = () => { loadStats(); loadCharts(); loadRecent() }
|
||||
const loadPlatformQuotas = async () => { try { const data = await getMyPlatformQuotas(); platformQuotas.value = data.platform_quotas ?? [] } catch (error) { console.warn('Failed to load platform quotas:', error); platformQuotas.value = [] } }
|
||||
const refreshAll = () => { loadStats(); loadCharts(); loadRecent(); loadPlatformQuotas() }
|
||||
|
||||
onMounted(() => { refreshAll() })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user