Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
@@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => {
})
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
})
it('requests the user group leaderboard with scoped cache parameters', async () => {
const groupParams = {
...params,
metric: 'cost' as const,
offset: 10,
limit: 10,
include_inactive: true,
}
getMock.mockResolvedValueOnce({
data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' },
})
await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({
attribution: 'current_membership',
})
expect(buildCacheKeyMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
groupParams
)
expect(cachedRequestMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
expect.any(Function),
20 * 1000
)
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', {
params: groupParams,
})
})
})
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, patchMock } = vi.hoisted(() => ({ getMock: vi.fn(), patchMock: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: getMock, patch: patchMock } }))
import { getPoolOverview, listPoolKeys, listPoolScores } from '@/api/endpoints/pool'
import { getProvider, updateProvider } from '@/api/endpoints/providers'
import { cache } from '@/utils/cache'
const options = { cacheTtlMs: 30_000 }
const provider = { id: 'codex', pool_advanced: { reserve_minimum_quota: false } }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
beforeEach(() => {
cache.clear()
getMock.mockReset()
patchMock.mockReset()
patchMock.mockResolvedValue({ data: provider })
})
describe('provider pool settings cache invalidation', () => {
it('refreshes every cached key page, score page and overview after changing pool settings', async () => {
getMock.mockResolvedValue({ data: { version: 'old' } })
await getPoolOverview(options)
await listPoolKeys('codex', {}, options)
await listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options)
await listPoolScores('codex', {}, options)
await listPoolKeys('codex-other', {}, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
getMock.mockResolvedValue({ data: { version: 'new' } })
await expect(getPoolOverview(options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options))
.resolves.toEqual({ version: 'new' })
await expect(listPoolScores('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex-other', {}, options)).resolves.toEqual({ version: 'old' })
})
it('does not reuse or cache a key request started before the settings were saved', async () => {
const oldResponse = deferred<{ data: { version: string } }>()
const newResponse = deferred<{ data: { version: string } }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockReturnValueOnce(newResponse.promise)
const oldRequest = listPoolKeys('codex', { page: 2 }, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { version: 'old' } })
await oldRequest
const deduped = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
newResponse.resolve({ data: { version: 'new' } })
await expect(newRequest).resolves.toEqual({ version: 'new' })
await expect(deduped).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2 }, options)).resolves.toEqual({ version: 'new' })
})
it('does not reuse a provider detail request started before a successful save', async () => {
const oldResponse = deferred<{ data: typeof provider }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockResolvedValueOnce({ data: provider })
const oldRequest = getProvider('codex')
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = getProvider('codex')
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { ...provider, pool_advanced: { reserve_minimum_quota: true } } })
await oldRequest
await expect(newRequest).resolves.toMatchObject(provider)
})
})
+23 -1
View File
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
cachedRequest: cachedRequestMock,
}))
import { usersApi } from '@/api/users'
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
describe('usersApi admin list query', () => {
beforeEach(() => {
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
})
})
describe('user batch wallet balance payload', () => {
it('builds an addition payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
operation: 'add',
amount: 12.5,
})
})
it('builds a deduction payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
operation: 'deduct',
amount: 4,
})
})
it('rejects zero, blank, and non-finite amounts', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
})
})
+34
View File
@@ -709,6 +709,8 @@ export interface LeaderboardItem {
requests: number
tokens: number
cost: number
member_count?: number
active_member_count?: number
}
export interface LeaderboardResponse {
@@ -717,6 +719,7 @@ export interface LeaderboardResponse {
metric: string
start_date?: string | null
end_date?: string | null
attribution?: 'current_membership'
}
export interface CostForecastResponse {
@@ -1298,6 +1301,7 @@ export const adminApi = {
model?: string
include_inactive?: boolean
exclude_admin?: boolean
user_group_id?: string
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
@@ -1312,6 +1316,35 @@ export const adminApi = {
)
},
async getLeaderboardUserGroups(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>(
'/api/admin/stats/leaderboard/user-groups',
{ params }
)
return response.data
},
20 * 1000
)
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
@@ -1595,6 +1628,7 @@ export const adminApi = {
timezone?: string
tz_offset_minutes?: number
user_id?: string
user_group_id?: string
model?: string
provider_name?: string
},
+1
View File
@@ -219,6 +219,7 @@ export interface RequestDetail {
has_format_conversion?: boolean | null
model: string
target_model?: string | null // 映射后的目标模型名
response_model?: string | null // 上游响应体实际返回的模型名
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
+1 -1
View File
@@ -376,7 +376,7 @@ function jsonValueContainsAgentIdentity(value: unknown): boolean {
export interface DeviceAuthorizeRequest {
start_url?: string
region?: string
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser' | 'device'
login_option?: 'google' | 'github' | 'default'
redirect_uri?: string
proxy_node_id?: string
+10 -1
View File
@@ -1,5 +1,5 @@
import client from '../client'
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
import { buildCacheKey, cache, cachedRequest, dedupedRequest } from '@/utils/cache'
import type {
ClaudeCodeAdvancedConfig,
FailoverRulesConfig,
@@ -142,6 +142,15 @@ export async function updateProvider(
requestOptions?: ProviderRequestOptions,
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}`, data, requestOptions)
cache.delete(`providers:detail:${providerId}`)
if ('pool_advanced' in data) {
cache.delete('pool:overview')
for (const kind of ['keys', 'scores']) {
const prefix = `pool:${kind}:${providerId}`
cache.delete(prefix)
cache.deleteByPrefix(`${prefix}:`)
}
}
return normalizeProviderSummary(response.data)
}
+35 -1
View File
@@ -462,6 +462,23 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface XaiUpstreamMetadata {
updated_at?: number
subscription_title?: string
usage_percentage?: number
remaining_percentage?: number
usage_label?: string
usage_limit?: number
current_usage?: number
remaining?: number
next_reset_at?: number
prepaid_balance?: number
on_demand_cap?: number
on_demand_used?: number
on_demand_remaining?: number
period_type?: string
}
export interface GeminiCliTierMetadata {
id?: string | null
tierType?: string | null
@@ -512,14 +529,29 @@ export interface GeminiCliUpstreamMetadata {
quota_by_model?: Record<string, GeminiCliModelQuota> | null
}
export interface ClaudeCodeUpstreamMetadata {
updated_at?: number
five_hour_used_percent?: number
five_hour_reset_at?: number
seven_day_used_percent?: number
seven_day_reset_at?: number
seven_day_sonnet_used_percent?: number
seven_day_sonnet_reset_at?: number
seven_day_fable_used_percent?: number
seven_day_fable_reset_at?: number
reset_credits?: QuotaResetCreditsSnapshot
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
claude_code?: ClaudeCodeUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
windsurf?: WindsurfUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
gemini_cli?: GeminiCliUpstreamMetadata
xai?: XaiUpstreamMetadata
}
// 按格式的健康度数据
@@ -758,7 +790,7 @@ export interface HealthRelatedMonitorResponse {
related_providers: HealthRelatedMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'xai' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制:null/undefined 表示不限制
@@ -804,6 +836,8 @@ export interface PoolAdvancedConfig {
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
skip_exhausted_accounts?: boolean | null
// Codex only: treat remaining quota <= 1% as exhausted (default false).
reserve_minimum_quota?: boolean
// 旧字段(兼容读取)
lru_enabled?: boolean
scheduling_mode?: 'lru' | 'multi_score' | null
+3
View File
@@ -60,6 +60,7 @@ export interface UsageRecordDetail {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -392,6 +393,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -438,6 +440,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+4
View File
@@ -20,6 +20,7 @@ export interface UsageRecord {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -136,6 +137,7 @@ export interface UsageFilters {
from?: string
to?: string
user_id?: string // UUID
user_group_id?: string // UUID
provider_id?: string // UUID
model?: string
search?: string
@@ -641,6 +643,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -712,6 +715,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+9
View File
@@ -19,6 +19,7 @@ export interface UsageRecord {
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
response_model?: string | null // 上游响应体实际返回的模型名
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
@@ -65,5 +66,13 @@ export interface UsageRecord {
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
/**
* 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。
* 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了",
* 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。
*/
has_skipped_candidate?: boolean
/** 被跳过候选的原因列表(后端已按候选顺序去重) */
skipped_candidate_reasons?: string[]
image_progress?: ImageProgress | null
}
+33 -2
View File
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
role: UserRole
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
export type UserBatchBalanceOperation = 'add' | 'deduct'
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
export interface UserBatchBalanceAdjustmentPayload {
operation: UserBatchBalanceOperation
amount: number
}
export function buildUserBatchBalanceAdjustmentPayload(
operation: UserBatchBalanceOperation,
amountInput: string | number,
): UserBatchBalanceAdjustmentPayload | null {
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
if (!Number.isFinite(amount) || amount <= 0) return null
return { operation, amount }
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
export type UserBatchActionPayload =
| UserBatchAccessControlPayload
| UserBatchRolePayload
| UserBatchBalanceAdjustmentPayload
export interface UserBatchToggleActionRequest {
selection: UserBatchSelection
@@ -142,10 +161,18 @@ export interface UserBatchRoleActionRequest {
payload: UserBatchRolePayload
}
export interface UserBatchBalanceActionRequest {
selection: UserBatchSelection
action: 'adjust_wallet_balance'
payload: UserBatchBalanceAdjustmentPayload
idempotency_key: string
}
export type UserBatchActionRequest =
| UserBatchToggleActionRequest
| UserBatchAccessControlActionRequest
| UserBatchRoleActionRequest
| UserBatchBalanceActionRequest
export interface UserBatchActionFailure {
user_id: string
@@ -157,6 +184,10 @@ export interface UserBatchActionResponse {
success: number
failed: number
failures: UserBatchActionFailure[]
interrupted?: boolean
completed_user_ids?: string[]
uncertain_user_ids?: string[]
unprocessed_user_ids?: string[]
warnings?: UserBatchSelectionWarning[]
action?: string
modified_fields?: string[]
+1 -1
View File
@@ -24,7 +24,7 @@ const open = ref(false)
</button>
<span
role="tooltip"
class="pointer-events-none invisible absolute left-1/2 top-full z-[230] mt-2 w-max max-w-xs -translate-x-1/2 rounded-md border bg-popover px-3 py-2 text-xs leading-5 text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
class="pointer-events-none invisible absolute left-1/2 top-full z-[230] mt-2 w-max max-w-xs -translate-x-1/2 whitespace-pre-line rounded-md border bg-popover px-3 py-2 text-xs leading-5 text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
:class="open ? 'visible opacity-100' : ''"
>
{{ props.text }}
@@ -8,17 +8,17 @@
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
<SelectValue :placeholder="t('stats.metric.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
{{ t('stats.metric.requests') }}
</SelectItem>
<SelectItem value="tokens">
Tokens
{{ t('stats.metric.tokens') }}
</SelectItem>
<SelectItem value="cost">
成本
{{ t('stats.metric.cost') }}
</SelectItem>
</SelectContent>
</Select>
@@ -36,25 +36,31 @@
class="p-6"
>
<EmptyState
title="暂无数据"
description="当前时间范围内没有统计结果"
:title="t('stats.empty.title')"
:description="t('stats.empty.description')"
/>
</div>
<Table v-else>
<TableHeader>
<TableRow>
<TableHead class="w-16">
排名
{{ t('stats.column.rank') }}
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">
请求数
<TableHead>{{ t('stats.column.name') }}</TableHead>
<TableHead
v-if="showMemberCount"
class="text-right"
>
{{ t('stats.column.members') }}
</TableHead>
<TableHead class="text-right">
Tokens
{{ t('stats.metric.requests') }}
</TableHead>
<TableHead class="text-right">
成本
{{ t('stats.metric.tokens') }}
</TableHead>
<TableHead class="text-right">
{{ t('stats.metric.cost') }}
</TableHead>
</TableRow>
</TableHeader>
@@ -62,11 +68,22 @@
<TableRow
v-for="item in items"
:key="item.id"
:class="selectable ? 'cursor-pointer hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' : undefined"
:tabindex="selectable ? 0 : undefined"
@click="selectable && emit('select', item)"
@keydown.enter.prevent="selectable && emit('select', item)"
@keydown.space.prevent="selectable && emit('select', item)"
>
<TableCell class="font-medium">
{{ item.rank }}
</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell>{{ showMemberCount && item.id === '__ungrouped__' ? t('userStats.ungrouped') : item.name }}</TableCell>
<TableCell
v-if="showMemberCount"
class="text-right"
>
{{ item.active_member_count ?? 0 }} / {{ item.member_count ?? 0 }}
</TableCell>
<TableCell class="text-right">
{{ item.requests }}
</TableCell>
@@ -102,6 +119,7 @@ import {
TableRow
} from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
import type { LeaderboardItem } from '@/api/admin'
interface Props {
@@ -110,17 +128,24 @@ interface Props {
metric: 'requests' | 'tokens' | 'cost'
loading?: boolean
showMetricSelect?: boolean
showMemberCount?: boolean
selectable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showMetricSelect: true
showMetricSelect: true,
showMemberCount: false,
selectable: false
})
const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
(e: 'select', value: LeaderboardItem): void
}>()
const { t } = useI18n()
const metric = computed(() => props.metric)
function emitMetric(value: string) {
@@ -5,6 +5,7 @@
:description="isEditMode ? '修改模型配置和价格信息' : ''"
:icon="isEditMode ? SquarePen : Layers"
:size="isEditMode ? '4xl' : '3xl'"
:z-index="zIndex"
@update:model-value="handleDialogUpdate"
>
<div
@@ -869,6 +870,8 @@ import {
const props = defineProps<{
open: boolean
model?: GlobalModelResponse | null
// 嵌套在其他弹窗内部时使用更高的层级,避免被父弹窗遮挡
zIndex?: number
}>()
const emit = defineEmits<{
@@ -0,0 +1,93 @@
import { createApp, nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setI18nLocale } from '@/i18n'
import UserUsageStats from '../users/UserUsageStats.vue'
const api = vi.hoisted(() => ({
users: vi.fn(), groups: vi.fn(), members: vi.fn(),
userLeaderboard: vi.fn(), groupLeaderboard: vi.fn(), summary: vi.fn(), series: vi.fn(),
}))
vi.mock('@/api/users', () => ({ usersApi: { getAllUsers: api.users, listUserGroups: api.groups, listUserGroupMembers: api.members } }))
vi.mock('@/api/admin', () => ({ adminApi: { getLeaderboardUsers: api.userLeaderboard, getLeaderboardUserGroups: api.groupLeaderboard, getTimeSeries: api.series } }))
vi.mock('@/api/usage', () => ({ usageApi: { getUsageStats: api.summary } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common', () => ({ EmptyState: { render: () => null }, LoadingState: { render: () => null }, TimeRangePicker: { render: () => null } }))
vi.mock('@/components/ui', async importOriginal => {
const original = await importOriginal<object>()
const { defineComponent, h } = await import('vue')
return {
...original,
Select: defineComponent({
props: { modelValue: String }, emits: ['update:modelValue'],
setup: (props, { slots, emit }) => () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
}, slots.default?.()),
}),
SelectTrigger: { render: () => null },
SelectContent: defineComponent({ inheritAttrs: false, setup: (_, { slots }) => () => slots.default?.() }),
SelectItem: defineComponent({ props: { value: String }, setup: (props, { slots }) => () => h('option', { value: props.value }, slots.default?.()) }),
}
})
let unmount = () => {}
async function settle() {
for (let index = 0; index < 10; index += 1) await Promise.resolve()
await nextTick()
}
async function mount() {
const root = document.createElement('div')
const app = createApp(UserUsageStats)
app.mount(root)
unmount = () => app.unmount()
await settle()
return root
}
async function select(root: HTMLElement, index: number, value: string) {
const control = root.querySelectorAll('select')[index]
control.value = value
control.dispatchEvent(new Event('change'))
await nextTick()
await vi.advanceTimersByTimeAsync(120)
await settle()
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setI18nLocale('en-US')
api.users.mockResolvedValue([{ id: 'user-1', username: 'Alice', is_active: true, groups: [{ id: 'group-1' }] }, { id: 'user-2', username: 'Bob', is_active: true, groups: [] }])
api.groups.mockResolvedValue({ items: [{ id: 'group-1', name: 'Engineering' }, { id: 'group-2', name: 'Support' }] })
api.members.mockResolvedValue([{ id: 'user-1', is_active: true, is_deleted: false }])
api.userLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.groupLeaderboard.mockResolvedValue({ items: [], total: 0 })
api.summary.mockResolvedValue({ total_requests: 5, total_tokens: 100, total_cost: 2 })
api.series.mockResolvedValue([])
})
afterEach(() => { unmount(); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('user and group usage statistics', () => {
it('applies group scope to summary, trends and member rankings, and can compare groups', async () => {
const root = await mount()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_id: 'user-1' }))
await select(root, 0, 'user_group')
expect(api.groupLeaderboard).toHaveBeenCalled()
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1' }))
expect(api.summary.mock.lastCall?.[0]).not.toHaveProperty('user_id')
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: 'group-1', limit: 10 }))
expect(api.members).toHaveBeenLastCalledWith('group-1')
await select(root, 2, 'group-2')
expect(api.series).toHaveBeenCalledWith(expect.objectContaining({ user_group_id: 'group-2' }))
expect(root.textContent).toContain('Group member leaderboard')
})
it('keeps the ungrouped sentinel across scoped queries without fetching a fictitious group', async () => {
const root = await mount()
await select(root, 0, 'user_group')
api.members.mockClear()
await select(root, 1, '__ungrouped__')
expect(api.summary).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.series).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.userLeaderboard).toHaveBeenLastCalledWith(expect.objectContaining({ user_group_id: '__ungrouped__' }))
expect(api.members).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,4 @@
import { createApp, nextTick, type Component } from 'vue'
import { createApp, h, nextTick, type Component } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import UserStats from '@/views/admin/UserStats.vue'
@@ -12,6 +12,7 @@ vi.mock('@/api/admin-wallets', () => ({ adminWalletApi: { listWallets: accountAp
vi.mock('@/api/users', () => ({ usersApi: { listUserPlanEntitlements: accountApi.plans } }))
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/overview/users/UserUsageStats.vue', () => ({ __esModule: true, default: { render: () => h('div', { 'data-user-usage-stats': '' }, 'Usage statistics') } }))
const range = 'from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&timezone=UTC'
const meta = {
schema_version: 1, metric_version: 'overview-v2', scope: { kind: 'installation' },
@@ -63,13 +64,27 @@ beforeEach(() => {
afterEach(() => { cleanup.splice(0).forEach(fn => fn()); vi.useRealTimers(); setI18nLocale('zh-CN') })
describe('enterprise user accounts', () => {
it('loads usage and group statistics only when selected, preserving the account view', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
button(root, '使用统计').click()
await settle()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).not.toBeNull()
root.querySelector<HTMLButtonElement>('button[data-value="accounts"]')?.click()
await settle()
expect(root.querySelector('[data-user-usage-stats]')).toBeNull()
expect(section(root, '[data-user-accounts]').textContent).toContain(employee.username)
expect(api.users).toHaveBeenCalledTimes(1)
})
it('shows full-roster financial totals, keeps zero-use users and delegates sorting/pagination', async () => {
const { root } = await mount(UserStats, `/admin/user-stats?${range}&attribution_kind=standalone&model=legacy`)
expect(root.textContent).toContain('Zero Usage Employee')
expect(section(root, '[data-user-summary="consumption"]').textContent).toContain('432.25')
expect(section(root, '[data-user-summary="activity"]').textContent).toMatch(/7\s*\/ 62/)
expect(root.querySelector('select[aria-label="归属"]')).toBeNull()
expect(root.querySelector('[role="tablist"]')).toBeNull()
expect(root.querySelector('button[data-value="accounts"][data-state="active"]')).not.toBeNull()
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'desc', limit: 25, offset: 0 })
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('attribution_kind')
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('model')
@@ -0,0 +1,543 @@
<template>
<div
class="space-y-6"
data-user-usage-stats
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 class="text-lg font-semibold">
{{ t('userStats.title') }}
</h1>
<p class="text-xs text-muted-foreground">
{{ t('userStats.description') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="scope">
<SelectTrigger class="h-8 w-32 text-xs">
<SelectValue :placeholder="t('userStats.scope.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
{{ t('userStats.scope.user') }}
</SelectItem>
<SelectItem value="user_group">
{{ t('userStats.scope.userGroup') }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="selectedEntityId">
<SelectTrigger class="h-8 w-52 text-xs">
<SelectValue :placeholder="scope === 'user' ? t('userStats.select.user') : t('userStats.select.userGroup')" />
</SelectTrigger>
<SelectContent
:search-threshold="0"
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
>
<SelectItem
v-for="entity in allEntities"
:key="entity.id"
:value="entity.id"
>
{{ entity.name }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="compareEntityId">
<SelectTrigger class="h-8 w-52 text-xs">
<SelectValue :placeholder="t('userStats.compare.placeholder')" />
</SelectTrigger>
<SelectContent
:search-threshold="0"
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
>
<SelectItem value="__none__">
{{ t('userStats.compare.none') }}
</SelectItem>
<SelectItem
v-for="entity in comparisonEntities"
:key="`compare-${entity.id}`"
:value="entity.id"
>
{{ entity.name }}
</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<LeaderboardTable
:title="scope === 'user' ? t('userStats.leaderboard.user') : t('userStats.leaderboard.userGroup')"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
:show-member-count="scope === 'user_group'"
selectable
@update:metric="metric = $event"
@select="selectLeaderboardItem"
>
<template #pagination>
<div class="flex items-center justify-between border-t px-4 py-3 text-xs text-muted-foreground">
<span>{{ t('userStats.pagination.summary', { total: leaderboardTotal, page: currentPage }) }}</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
:disabled="leaderboardOffset === 0 || leaderboardLoading"
@click="changeLeaderboardPage(-1)"
>
{{ t('userStats.pagination.previous') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!hasNextLeaderboardPage || leaderboardLoading"
@click="changeLeaderboardPage(1)"
>
{{ t('userStats.pagination.next') }}
</Button>
</div>
</div>
</template>
</LeaderboardTable>
<Card class="space-y-3 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.summary.user') : t('userStats.summary.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{{ selectedEntityName || t('userStats.selectPrompt') }}
</p>
</div>
<div
v-if="summaryLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="grid grid-cols-2 gap-3 text-sm"
>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.requests') }}
</div>
<div class="font-semibold">
{{ usageSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.tokens') }}
</div>
<div class="font-semibold">
{{ formatTokens(usageSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.cost') }}
</div>
<div class="font-semibold">
{{ formatCurrency(usageSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('stats.metric.errorRate') }}
</div>
<div class="font-semibold">
{{ usageSummary?.error_rate ?? 0 }}%
</div>
</div>
<template v-if="scope === 'user_group'">
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.current') }}
</div>
<div class="font-semibold">
{{ groupMemberCount }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.active') }}
</div>
<div class="font-semibold">
{{ activeGroupMemberCount }}
</div>
</div>
</template>
</div>
</Card>
</div>
<LeaderboardTable
v-if="scope === 'user_group'"
:title="t('userStats.memberLeaderboard')"
:items="memberLeaderboard"
:metric="metric"
:loading="memberLeaderboardLoading"
:show-metric-select="false"
selectable
@select="selectMember"
/>
<Card class="space-y-4 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.trend.user') : t('userStats.trend.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{{ selectedEntityName || t('userStats.selectPrompt') }}
</p>
</div>
<div
v-if="seriesLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[280px]"
>
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card
v-if="comparisonSeries.length > 0"
class="space-y-4 p-4"
>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.comparisonTrend.user') : t('userStats.comparisonTrend.userGroup') }}
</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Button,
Card,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User, type UserGroup, type UserGroupMember } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
type StatsScope = 'user' | 'user_group'
type SelectableEntity = { id: string; name: string }
interface UsageSummary {
total_requests: number
total_tokens: number
total_cost: number
error_rate: number
}
interface TimeSeriesItem {
date: string
total_cost: number
}
const { t } = useI18n()
const PAGE_SIZE = 10
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const scope = ref<StatsScope>('user')
const users = ref<User[]>([])
const userGroups = ref<UserGroup[]>([])
const selectedUserId = ref('')
const selectedUserGroupId = ref('')
const compareUserId = ref('__none__')
const compareUserGroupId = ref('__none__')
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardTotal = ref(0)
const leaderboardOffset = ref(0)
const leaderboardLoading = ref(false)
const memberLeaderboard = ref<LeaderboardItem[]>([])
const memberLeaderboardLoading = ref(false)
const groupMemberCount = ref(0)
const activeGroupMemberCount = ref(0)
const usageSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
let leaderboardRequestId = 0
let panelRequestId = 0
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let panelDebounceTimer: ReturnType<typeof setTimeout> | null = null
let ready = false
const allEntities = computed<SelectableEntity[]>(() => scope.value === 'user'
? users.value.map(user => ({ id: user.id, name: user.username || user.email || user.id }))
: [...userGroups.value.map(group => ({ id: group.id, name: group.name })),
{ id: '__ungrouped__', name: t('userStats.ungrouped') }])
const selectedEntityId = computed({
get: () => scope.value === 'user' ? selectedUserId.value : selectedUserGroupId.value,
set: (value: string) => {
if (scope.value === 'user') selectedUserId.value = value
else selectedUserGroupId.value = value
}
})
const compareEntityId = computed({
get: () => scope.value === 'user' ? compareUserId.value : compareUserGroupId.value,
set: (value: string) => {
if (scope.value === 'user') compareUserId.value = value
else compareUserGroupId.value = value
}
})
const comparisonEntities = computed(() => allEntities.value.filter(
entity => entity.id !== selectedEntityId.value
))
const selectedEntityName = computed(() => allEntities.value.find(
entity => entity.id === selectedEntityId.value
)?.name ?? '')
const comparedEntityName = computed(() => allEntities.value.find(
entity => entity.id === compareEntityId.value
)?.name ?? '')
const currentPage = computed(() => Math.floor(leaderboardOffset.value / PAGE_SIZE) + 1)
const hasNextLeaderboardPage = computed(
() => leaderboardOffset.value + leaderboard.value.length < leaderboardTotal.value
)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
}
}
function scopeParams(id: string) {
return scope.value === 'user' ? { user_id: id } : { user_group_id: id }
}
function ensureSelectedEntity() {
const entities = allEntities.value
if (!entities.some(entity => entity.id === selectedEntityId.value)) {
selectedEntityId.value = entities[0]?.id ?? ''
}
if (compareEntityId.value !== '__none__' && !entities.some(entity => entity.id === compareEntityId.value)) {
compareEntityId.value = '__none__'
}
}
async function loadEntities() {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
}
async function loadLeaderboard() {
const requestId = ++leaderboardRequestId
leaderboardLoading.value = true
try {
const params = {
...buildTimeRangeParams(),
metric: metric.value,
limit: PAGE_SIZE,
offset: leaderboardOffset.value
}
const response = scope.value === 'user'
? await adminApi.getLeaderboardUsers(params)
: await adminApi.getLeaderboardUserGroups(params)
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
leaderboardTotal.value = response.total
} finally {
if (requestId === leaderboardRequestId) leaderboardLoading.value = false
}
}
async function loadPanels() {
const selectedId = selectedEntityId.value
const requestId = ++panelRequestId
if (!selectedId) {
usageSummary.value = null
series.value = []
comparisonSeries.value = []
memberLeaderboard.value = []
groupMemberCount.value = 0
activeGroupMemberCount.value = 0
return
}
summaryLoading.value = true
seriesLoading.value = true
memberLeaderboardLoading.value = scope.value === 'user_group'
try {
const primaryParams = { ...buildTimeRangeParams(), ...scopeParams(selectedId) }
const shouldCompare = compareEntityId.value !== '__none__'
const comparisonPromise: Promise<TimeSeriesItem[]> = shouldCompare
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
...scopeParams(compareEntityId.value)
})
: Promise.resolve([])
const memberPromise: Promise<{ items: LeaderboardItem[] }> = scope.value === 'user_group'
? adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
user_group_id: selectedId,
limit: PAGE_SIZE
})
: Promise.resolve({ items: [] })
const groupMembersPromise: Promise<UserGroupMember[]> = scope.value === 'user_group' && selectedId !== '__ungrouped__'
? usersApi.listUserGroupMembers(selectedId)
: Promise.resolve([])
const [summary, primarySeries, compareSeries, members, groupMembers] = await Promise.all([
usageApi.getUsageStats(primaryParams),
adminApi.getTimeSeries(primaryParams),
comparisonPromise,
memberPromise,
groupMembersPromise
])
if (requestId !== panelRequestId) return
usageSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
series.value = primarySeries
comparisonSeries.value = compareSeries
memberLeaderboard.value = members.items
const ungroupedUsers = users.value.filter(user => user.groups?.length === 0)
groupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.length : groupMembers.filter(member => !member.is_deleted).length
activeGroupMemberCount.value = selectedId === '__ungrouped__'
? ungroupedUsers.filter(user => user.is_active).length
: groupMembers.filter(member => !member.is_deleted && member.is_active).length
} finally {
if (requestId === panelRequestId) {
summaryLoading.value = false
seriesLoading.value = false
memberLeaderboardLoading.value = false
}
}
}
function selectLeaderboardItem(item: LeaderboardItem) {
selectedEntityId.value = item.id
}
function selectMember(item: LeaderboardItem) {
scope.value = 'user'
selectedUserId.value = item.id
}
function changeLeaderboardPage(direction: -1 | 1) {
leaderboardOffset.value = Math.max(0, leaderboardOffset.value + direction * PAGE_SIZE)
void loadLeaderboard()
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [{
label: t('stats.metric.cost'),
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: selectedEntityName.value || t('userStats.chart.current'),
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: comparedEntityName.value || t('userStats.chart.comparison'),
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
}
]
}))
function scheduleLeaderboardLoad() {
if (!ready) return
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
void loadLeaderboard()
}, 120)
}
function schedulePanelLoad() {
if (!ready) return
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
panelDebounceTimer = setTimeout(() => {
panelDebounceTimer = null
void loadPanels()
}, 120)
}
watch(scope, () => {
leaderboardOffset.value = 0
ensureSelectedEntity()
scheduleLeaderboardLoad()
schedulePanelLoad()
})
watch([timeRange, metric], () => {
leaderboardOffset.value = 0
scheduleLeaderboardLoad()
schedulePanelLoad()
}, { deep: true })
watch([selectedEntityId, compareEntityId], schedulePanelLoad)
onMounted(async () => {
await loadEntities()
ready = true
await Promise.all([loadLeaderboard(), loadPanels()])
})
onUnmounted(() => {
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
leaderboardRequestId += 1
panelRequestId += 1
})
</script>
@@ -72,6 +72,33 @@
</div>
</div>
<div
v-if="isCodex"
class="flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-card/70 p-4"
>
<div class="space-y-1">
<label
for="pool-reserve-minimum-quota"
class="text-sm font-medium"
>
保留最低额度
</label>
<p
id="pool-reserve-minimum-quota-description"
class="text-xs leading-5 text-muted-foreground"
>
账号剩余额度不高于 1% 时提前标记为额度耗尽并停止调度,待额度恢复后再使用。
</p>
</div>
<Switch
id="pool-reserve-minimum-quota"
v-model="reserveMinimumQuota"
aria-describedby="pool-reserve-minimum-quota-description"
:disabled="loading"
class="mt-0.5 shrink-0"
/>
</div>
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
<div class="space-y-1">
@@ -334,7 +361,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Free/Team 优先',
description: '兼容旧配置:优先消耗 Free、Team 或两者',
evidence_hint: '依据 plan_type,保留旧 free_only/team_only/both 语义',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: [
{ value: 'free_only', label: 'Free' },
{ value: 'team_only', label: 'Team' },
@@ -347,7 +374,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Free 优先',
description: '优先消耗 Free 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Free 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -356,7 +383,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Team 优先',
description: '优先消耗 Team 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Team 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -365,7 +392,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Plus 优先',
description: '优先消耗 Plus 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Plus 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -374,7 +401,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Pro 优先',
description: '优先消耗 Pro 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Pro 账号优先调度)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
modes: null,
default_mode: null,
},
@@ -392,7 +419,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: '额度刷新优先',
description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
default_enabled_providers: ['codex', 'windsurf'],
modes: null,
default_mode: null,
@@ -437,6 +464,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
const { success, error: showError } = useToast()
const loading = ref(false)
const reserveMinimumQuota = ref(false)
const isCodex = computed(() => normalizeProviderType(props.providerType) === 'codex')
let dialogRevision = 0
const presetDefs = ref<PoolPresetMeta[]>([])
const presetDefsLoaded = ref(false)
@@ -850,6 +879,7 @@ watch([() => props.modelValue, () => props.providerId], async ([open]) => {
const revision = ++dialogRevision
loading.value = false
if (!open) return
reserveMinimumQuota.value = props.currentConfig?.reserve_minimum_quota === true
await ensurePresetDefsLoaded()
if (!props.modelValue || dialogRevision !== revision) return
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
@@ -883,6 +913,7 @@ async function handleSave() {
const latestAdvanced = latestProvider.pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
...(isCodex.value ? { reserve_minimum_quota: reserveMinimumQuota.value } : {}),
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { PoolAdvancedConfig } from '@/api/endpoints/types/provider'
import PoolSchedulingDialog from '../PoolSchedulingDialog.vue'
const endpointMocks = vi.hoisted(() => ({
getPoolSchedulingPresets: vi.fn(),
getProvider: vi.fn(),
updateProvider: vi.fn(),
}))
vi.mock('@/api/endpoints', () => ({
getProvider: endpointMocks.getProvider,
updateProvider: endpointMocks.updateProvider,
}))
vi.mock('@/api/endpoints/pool', () => ({
getPoolSchedulingPresets: endpointMocks.getPoolSchedulingPresets,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn() }),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
async function settle(): Promise<void> {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountDialog(providerType = 'codex', currentConfig: PoolAdvancedConfig | null = null) {
const root = document.createElement('div')
document.body.appendChild(root)
const open = ref(false)
const TestHost = defineComponent({
setup() {
void nextTick(() => { open.value = true })
return () => h(PoolSchedulingDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerType,
currentConfig,
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
})
const app = createApp(TestHost)
app.mount(root)
mountedApps.push({ app, root })
return open
}
function quotaSwitch() {
return document.body.querySelector<HTMLButtonElement>('#pool-reserve-minimum-quota')
}
async function saveDialog() {
const saveButton = [...document.body.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '保存')
expect(saveButton).toBeDefined()
saveButton?.click()
await settle()
}
beforeEach(() => {
endpointMocks.getPoolSchedulingPresets.mockReset()
endpointMocks.getProvider.mockReset()
endpointMocks.updateProvider.mockReset()
endpointMocks.getPoolSchedulingPresets.mockResolvedValue([{
name: 'lru',
label: 'LRU 轮转',
description: '最久未使用的 Key 优先',
providers: [],
default_enabled: true,
modes: null,
default_mode: null,
mutex_group: 'distribution_mode',
}])
endpointMocks.getProvider.mockResolvedValue({ id: 'provider-1', pool_advanced: {} })
endpointMocks.updateProvider.mockResolvedValue({ id: 'provider-1' })
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('PoolSchedulingDialog minimum quota reserve', () => {
it('defaults to disabled and saves enabling it while preserving the latest settings', async () => {
endpointMocks.getProvider.mockResolvedValue({
id: 'provider-1',
pool_advanced: { rate_limit_cooldown_seconds: 900, skip_exhausted_accounts: false },
})
mountDialog('codex', { rate_limit_cooldown_seconds: 300 })
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('false')
expect(document.body.textContent).toContain('剩余额度不高于 1%')
quotaSwitch()?.click()
await nextTick()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
rate_limit_cooldown_seconds: 900,
skip_exhausted_accounts: false,
reserve_minimum_quota: true,
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
it('loads an enabled reserve and saves disabling it', async () => {
mountDialog('codex', { reserve_minimum_quota: true })
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('true')
quotaSwitch()?.click()
await nextTick()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
reserve_minimum_quota: false,
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
it('discards unsaved reserve changes when the dialog is reopened', async () => {
const open = mountDialog()
await settle()
quotaSwitch()?.click()
await nextTick()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('true')
open.value = false
await settle()
open.value = true
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('false')
expect(endpointMocks.updateProvider).not.toHaveBeenCalled()
})
it.each(['openai', 'kiro'])('does not expose or add the reserve setting for %s', async (providerType) => {
mountDialog(providerType)
await settle()
expect(quotaSwitch()).toBeNull()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
})
@@ -62,6 +62,16 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
class="shrink-0 gap-1.5"
data-testid="batch-assign-create-model"
@click="openCreateModelDialog"
>
<Plus class="w-4 h-4" />
创建模型
</Button>
</div>
<!-- 模型列表 -->
@@ -98,6 +108,7 @@
v-for="model in filteredGlobalModels"
:key="model.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
:data-testid="`batch-assign-model-${model.id}`"
@click="toggleGlobalModelSelection(model.id)"
>
<div
@@ -138,6 +149,15 @@
</div>
</div>
</div>
<!-- 创建统一模型对话框(嵌套,创建成功后刷新下方模型列表)
注意:不使用 v-if 挂载,GlobalModelFormDialog 依赖 open 变化来加载模型目录 -->
<GlobalModelFormDialog
:open="createModelDialogOpen"
:z-index="70"
@update:open="createModelDialogOpen = $event"
@success="handleGlobalModelCreated"
/>
</template>
<template #footer>
<div class="flex items-center justify-between w-full">
@@ -169,10 +189,11 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, Search, Check, ListChecks } from 'lucide-vue-next'
import { Layers, Loader2, Search, Check, ListChecks, Plus } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -226,6 +247,8 @@ const loadingGlobalModels = ref(false)
const loadingProviderKeys = ref(false)
const saving = ref(false)
const fetchingAutoMatchedModels = ref(false)
// 创建统一模型对话框是否打开(嵌套在本弹窗内)
const createModelDialogOpen = ref(false)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
@@ -255,15 +278,29 @@ const existingGlobalModelIds = computed(() => {
)
})
// 过滤后的全局模型
function globalModelMatchesQuery(model: GlobalModelResponse, query: string): boolean {
if (!query) return true
return model.name.toLowerCase().includes(query) || model.display_name.toLowerCase().includes(query)
}
function compareGlobalModelsByName(left: GlobalModelResponse, right: GlobalModelResponse): number {
const nameA = (left.display_name || left.name || '').toLowerCase()
const nameB = (right.display_name || right.name || '').toLowerCase()
return nameA.localeCompare(nameB)
}
// 过滤后的全局模型:当前已勾选/已关联的排在可见结果顶部,便于取消关联
const filteredGlobalModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
return allGlobalModels.value.filter(m => {
if (query && !m.name.toLowerCase().includes(query) && !m.display_name.toLowerCase().includes(query)) {
return false
}
return true
})
const selectedIds = selectedGlobalModelIds.value
const matched = allGlobalModels.value.filter(model => globalModelMatchesQuery(model, query))
const pinned = matched
.filter(model => selectedIds.has(model.id))
.sort(compareGlobalModelsByName)
const rest = matched
.filter(model => !selectedIds.has(model.id))
.sort(compareGlobalModelsByName)
return [...pinned, ...rest]
})
// 全局模型是否全选
@@ -407,6 +444,16 @@ async function applyAutoMatchFromKey(key: AutoMatchKey) {
}
}
// 打开"创建统一模型"对话框
function openCreateModelDialog() {
createModelDialogOpen.value = true
}
// 统一模型创建成功:刷新下方全局模型列表,便于直接勾选新模型
async function handleGlobalModelCreated() {
await loadGlobalModels()
}
// 处理关闭
async function handleClose() {
if (hasChanges.value) {
@@ -502,6 +549,7 @@ watch(
initialGlobalModelIds.value = new Set()
providerKeys.value = []
fetchingAutoMatchedModels.value = false
createModelDialogOpen.value = false
}
},
{ immediate: true },
@@ -244,6 +244,129 @@
</div>
</template>
<!-- xAI: 设备授权 -->
<template v-else-if="isXaiProvider">
<div class="space-y-3">
<div class="h-[265px]">
<div
v-if="device.status === 'error' || device.status === 'expired'"
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
>
<div class="flex flex-col items-center text-center space-y-3">
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle class="w-5 h-5 text-destructive" />
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-destructive">
{{ legacyT(device.status === 'expired' ? '授权已过期' : '授权失败') }}
</p>
<p class="text-xs text-muted-foreground">
{{ legacyT(device.error || '请重试') }}
</p>
</div>
<Button
size="sm"
variant="outline"
@click="resetDevice"
>
{{ legacyT('重新开始') }}
</Button>
</div>
</div>
<div
v-else-if="device.starting && !device.session_id"
class="flex items-center justify-center py-12"
>
<div class="text-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
<p class="text-xs text-muted-foreground">
{{ legacyT('正在准备设备授权...') }}
</p>
</div>
</div>
<div
v-else-if="device.session_id && device.status === 'pending'"
class="rounded-xl border border-border bg-muted/20 p-5"
>
<div class="flex flex-col items-center text-center space-y-4">
<div class="relative">
<div class="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
<div class="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<ExternalLink class="w-5 h-5 text-primary" />
</div>
</div>
<div class="space-y-1">
<p class="text-sm font-medium">
{{ legacyT('在浏览器中输入设备码完成授权') }}
</p>
<p class="text-xs text-muted-foreground">
{{ legacyT('授权完成后此页面将自动更新') }}
</p>
</div>
<div
v-if="device.user_code"
class="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2"
>
<span class="text-lg font-mono font-bold tracking-[0.2em]">{{ device.user_code }}</span>
<button
class="p-1 rounded hover:bg-muted transition-colors"
:title="legacyT('复制设备码')"
@click="copyToClipboard(device.user_code)"
>
<Copy class="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
<span>{{ remainingText }}</span>
</div>
<div class="flex gap-2 w-full">
<Button
class="flex-1"
size="sm"
:disabled="!device.verification_uri_complete && !device.verification_uri"
@click="openDeviceVerificationUrl"
>
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('打开授权页面') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="!device.verification_uri_complete && !device.verification_uri"
@click="copyToClipboard(device.verification_uri_complete || device.verification_uri)"
>
<Copy class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-3"
>
<p class="text-xs text-muted-foreground text-center">
{{ legacyT('使用 xAI 设备授权登录 Grok CLI,或改为导入 API Key / Refresh Token。') }}
</p>
<Button
class="w-full"
:disabled="device.starting"
@click="startDeviceAuth"
>
{{ device.starting ? legacyT('正在准备授权...') : legacyT('开始授权') }}
</Button>
</div>
</div>
</div>
</template>
<!-- Kiro: 设备授权模式 -->
<template v-else-if="isKiroProvider">
<div class="space-y-3">
@@ -994,7 +1117,7 @@ let oauthInitRequestId = 0
let oauthCompleteRequestId = 0
// 设备授权状态
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center' | 'device'
type WindsurfLoginOption = 'default' | 'google' | 'github'
interface DeviceAuthState {
@@ -1075,9 +1198,10 @@ const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isXaiProvider = computed(() => (props.providerType || '').toLowerCase() === 'xai')
const isCodexProvider = computed(() => (props.providerType || '').toLowerCase() === 'codex')
const isClaudeCodeProvider = computed(() => (props.providerType || '').toLowerCase() === 'claude_code')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value || isXaiProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -1101,7 +1225,7 @@ const isManualDeviceCallbackPending = computed(() =>
const authorizationModeLabel = computed(() => {
if (isWindsurfProvider.value) return legacyT('浏览器登录')
if (isDeviceBrowserProvider.value) return legacyT('设备授权')
if (isXaiProvider.value || isDeviceBrowserProvider.value) return legacyT('设备授权')
return legacyT('获取授权')
})
@@ -1215,6 +1339,9 @@ const importManualPlaceholder = computed(() => {
if (isClaudeCodeProvider.value) {
return legacyT('粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容')
}
if (isXaiProvider.value) {
return legacyT('粘贴 xAI API Key、Access Token,或包含 refresh_token / api_key 的 JSON')
}
if (isWindsurfProvider.value) {
return legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
}
@@ -1477,11 +1604,17 @@ function resetDevice() {
totp.stop()
const { auth_type, start_url, region, totp_secret } = device.value
device.value = createInitialDeviceState()
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
device.value.auth_type = isXaiProvider.value
? 'device'
: isWindsurfProvider.value
? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default')
: auth_type
device.value.start_url = start_url
device.value.region = region
device.value.totp_secret = totp_secret
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
if (isXaiProvider.value) {
void ensureXaiDeviceAuth()
} else if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
void ensureKiroSocialDeviceAuth()
}
}
@@ -1500,6 +1633,8 @@ function resetForm() {
device.value = createInitialDeviceState()
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isXaiProvider.value) {
device.value.auth_type = 'device'
}
importText.value = ''
importing.value = false
@@ -1531,6 +1666,8 @@ function switchMode(newMode: DialogMode) {
if (newMode === 'oauth') {
if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else if (isXaiProvider.value) {
void ensureXaiDeviceAuth()
} else if (!oauth.value.authorization_url && !oauth.value.starting) {
initOAuth()
}
@@ -1833,6 +1970,29 @@ function parseImportText(text: string): {
return { refresh_token: trimmed }
}
if (isXaiProvider.value) {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken) ?? apiKey
if (refreshToken || accessToken) {
return {
refresh_token: refreshToken,
access_token: accessToken,
name: normalizeStringField(obj.name) ?? normalizeStringField(obj.email),
email: normalizeStringField(obj.email),
}
}
}
} catch {
// Raw xAI API keys / access tokens are imported as access_token.
}
return { access_token: trimmed }
}
if (isGrokProvider.value) {
const cookieImport = parseGrokCookieImport(trimmed)
if (cookieImport) {
@@ -2367,17 +2527,20 @@ async function startDeviceAuth() {
device.value.error = ''
try {
const isWindsurf = isWindsurfProvider.value
const isXai = isXaiProvider.value
const isBuilderID = requestedAuthType === 'builder_id'
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
const isSocial = !isXai && (requestedAuthType === 'google' || requestedAuthType === 'github')
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
const authTypeForRequest = isWindsurf
? 'browser'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
: isXai
? 'device'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
const resp = await startDeviceAuthorize(props.providerId, {
auth_type: authTypeForRequest,
login_option: isWindsurf ? windsurfLoginOption : undefined,
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
start_url: (isWindsurf || isXai) ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: (isWindsurf || isXai) ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
@@ -2417,6 +2580,13 @@ async function ensureKiroSocialDeviceAuth() {
await startDeviceAuth()
}
async function ensureXaiDeviceAuth() {
if (!props.open || !props.providerId || !isXaiProvider.value) return
if (device.value.starting) return
if (device.value.session_id && (device.value.status === 'pending' || device.value.status === 'authorized')) return
await startDeviceAuth()
}
function scheduleDevicePoll() {
if (devicePollTimer) clearTimeout(devicePollTimer)
devicePollTimer = setTimeout(() => pollDevice(), device.value.interval * 1000)
@@ -2525,6 +2695,9 @@ watch(
}
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isXaiProvider.value) {
device.value.auth_type = 'device'
void ensureXaiDeviceAuth()
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
@@ -2554,6 +2727,9 @@ watch(
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
? device.value.auth_type
: 'default'
} else if (props.open && isXaiProvider.value && mode.value === 'oauth') {
device.value.auth_type = 'device'
void ensureXaiDeviceAuth()
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}
@@ -406,6 +406,69 @@
</div>
</template>
</div>
<!-- Claude Code 账号额度(5H / 周窗口) -->
<div
v-if="provider.provider_type === 'claude_code' && hasClaudeCodeQuotaDisplayData(key)"
class="mt-2 p-2 rounded-md bg-muted/30"
>
<ProviderQuotaSectionHeader
:title="legacyT('账号配额')"
:loading="refreshingQuota"
:updated-text="getClaudeCodeQuotaUpdatedAt(key) ? formatUpdatedAt(getClaudeCodeQuotaUpdatedAt(key) || 0) : null"
/>
<div class="grid grid-cols-2 gap-3">
<ProviderQuotaProgressRow
v-for="item in getClaudeCodeQuotaItems(key)"
:key="item.code"
:label="item.label"
:used-percent="item.usedPercent"
:remaining-percent="item.remainingPercent"
:meter-class="getQuotaRemainingClass(item.usedPercent)"
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
>
<template #footer>
<div
v-if="item.resetSeconds !== null && item.remainingPercent < 100"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
<template v-if="item.resetSeconds > 0">
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
</template>
<template v-else>
{{ legacyT('已重置') }}
</template>
</div>
</template>
</ProviderQuotaProgressRow>
</div>
<div
v-if="getClaudeCodeResetCreditAvailableCount(key) !== null"
class="mt-3 border-t border-border/60 pt-2"
>
<div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
<span>{{ formatCodexResetCreditCountLabel(getClaudeCodeResetCreditAvailableCount(key)) }}</span>
<template v-if="getVisibleClaudeCodeResetCreditItems(key).length > 0">
<span aria-hidden="true">|</span>
<span>{{ legacyT('临近过期') }}</span>
<template
v-for="(item, itemIndex) in getVisibleClaudeCodeResetCreditItems(key)"
:key="`${item.displayKey}-${item.expiresAt}`"
>
<span
:title="item.title"
class="tabular-nums"
>
{{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
</span>
<span
v-if="itemIndex < getVisibleClaudeCodeResetCreditItems(key).length - 1"
aria-hidden="true"
>·</span>
</template>
</template>
</div>
</div>
</div>
<!-- Gemini CLI 上游模型配额 -->
<div
v-if="provider.provider_type === 'gemini_cli' && hasGeminiCliQuotaDisplayData(key)"
@@ -514,6 +577,65 @@
</div>
</template>
</div>
<!-- xAI / Grok Build 订阅额度 -->
<div
v-if="provider.provider_type === 'xai' && hasXaiQuotaDisplayData(key)"
class="mt-2 p-2 rounded-md bg-muted/30"
>
<ProviderQuotaSectionHeader
:title="legacyT('账号配额')"
:loading="refreshingQuota"
:updated-text="getXaiQuotaDisplay(key)?.updated_at ? formatKiroUpdatedAt(getXaiQuotaDisplay(key)?.updated_at || 0) : null"
/>
<div class="space-y-2">
<ProviderQuotaProgressRow
v-if="getXaiQuotaDisplay(key)?.usage_percentage !== undefined || getXaiQuotaDisplay(key)?.remaining_percentage !== undefined"
:label="legacyT(getXaiUsageLabel(key))"
:used-percent="getXaiUsedPercent(key)"
:remaining-percent="getXaiRemainingPercent(key)"
:meter-class="getQuotaRemainingClass(getXaiUsedPercent(key))"
:bar-class="getQuotaRemainingBarColor(getXaiUsedPercent(key))"
:reset-text="getXaiQuotaDisplay(key)?.next_reset_at
? `${formatKiroResetTime(getXaiQuotaDisplay(key)?.next_reset_at)}${legacyT('重置')}`
: null"
>
<template
v-if="getXaiQuotaDisplay(key)?.usage_limit != null"
#footer
>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.current_usage) }} /
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.usage_limit) }}
</span>
<span v-if="getXaiQuotaDisplay(key)?.next_reset_at">
{{ formatKiroResetTime(getXaiQuotaDisplay(key)?.next_reset_at) }}{{ legacyT('重置') }}
</span>
</div>
</template>
</ProviderQuotaProgressRow>
<div
v-if="getXaiQuotaDisplay(key)?.prepaid_balance != null"
class="text-[10px] text-muted-foreground"
>
{{ legacyT('预付额度') }}: {{ formatKiroUsage(getXaiQuotaDisplay(key)?.prepaid_balance) }}
</div>
<ProviderQuotaProgressRow
v-if="getXaiQuotaDisplay(key)?.on_demand_cap"
:label="legacyT('按需额度')"
:used-percent="getXaiOnDemandUsedPercent(key)"
:meter-class="getQuotaRemainingClass(getXaiOnDemandUsedPercent(key))"
:bar-class="getQuotaRemainingBarColor(getXaiOnDemandUsedPercent(key))"
>
<template #footer>
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.on_demand_used) }} /
{{ formatKiroUsage(getXaiQuotaDisplay(key)?.on_demand_cap) }}
</div>
</template>
</ProviderQuotaProgressRow>
</div>
</div>
<!-- Windsurf 上游额度信息 -->
<div
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
@@ -941,6 +1063,7 @@ import {
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { getI18nLocale, useI18n } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
@@ -1005,6 +1128,7 @@ import type {
GrokUpstreamMetadata,
KiroUpstreamMetadata,
WindsurfUpstreamMetadata,
XaiUpstreamMetadata,
QuotaResetCreditsSnapshot,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
@@ -1858,7 +1982,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'claude_code' | 'grok' | 'xai',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2179,6 +2303,90 @@ function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
}
function getXaiQuotaDisplay(key: EndpointAPIKey): XaiUpstreamMetadata | null {
const quota = getQuotaSnapshotForProvider(key, 'xai')
if (!quota) return null
const display: XaiUpstreamMetadata = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota.plan_type) display.subscription_title = quota.plan_type
const usageWindow =
getQuotaWindow(quota, 'usage')
?? getQuotaWindowByScope(quota, 'account')[0]
?? null
if (usageWindow) {
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
const remainingPercent = getQuotaWindowRemainingPercent(usageWindow)
if (usedPercent !== undefined) display.usage_percentage = usedPercent
if (remainingPercent !== undefined) display.remaining_percentage = remainingPercent
const usageLabel = String(usageWindow.label || '').trim()
if (usageLabel) display.usage_label = usageLabel
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
const nextResetAt =
getQuotaWindowResetAt(usageWindow)
?? (() => {
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
if (updatedAt === undefined || resetSeconds === undefined) return undefined
return updatedAt + resetSeconds
})()
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
}
const prepaidWindow = getQuotaWindow(quota, 'prepaid')
if (typeof prepaidWindow?.remaining_value === 'number') {
display.prepaid_balance = prepaidWindow.remaining_value
}
const onDemandWindow = getQuotaWindow(quota, 'on_demand')
if (typeof onDemandWindow?.limit_value === 'number') display.on_demand_cap = onDemandWindow.limit_value
if (typeof onDemandWindow?.used_value === 'number') display.on_demand_used = onDemandWindow.used_value
if (typeof onDemandWindow?.remaining_value === 'number') display.on_demand_remaining = onDemandWindow.remaining_value
return Object.keys(display).length > 0 ? display : null
}
function hasXaiQuotaDisplayData(key: EndpointAPIKey): boolean {
const xai = getXaiQuotaDisplay(key)
return !!xai && (
xai.usage_percentage !== undefined
|| xai.remaining_percentage !== undefined
|| xai.prepaid_balance !== undefined
|| xai.on_demand_cap !== undefined
)
}
function getXaiUsageLabel(key: EndpointAPIKey): string {
const display = getXaiQuotaDisplay(key)
if (display?.usage_label) return display.usage_label
const title = display?.subscription_title
return title ? `使用额度 (${title})` : '使用额度'
}
function getXaiUsedPercent(key: EndpointAPIKey): number {
return Math.min(Math.max(100 - getXaiRemainingPercent(key), 0), 100)
}
function getXaiRemainingPercent(key: EndpointAPIKey): number {
const xai = getXaiQuotaDisplay(key)
if (xai?.remaining_percentage != null && Number.isFinite(xai.remaining_percentage)) {
return Math.min(Math.max(xai.remaining_percentage, 0), 100)
}
if (xai?.usage_percentage != null && Number.isFinite(xai.usage_percentage)) {
return Math.min(Math.max(100 - xai.usage_percentage, 0), 100)
}
return 0
}
function getXaiOnDemandUsedPercent(key: EndpointAPIKey): number {
const xai = getXaiQuotaDisplay(key)
if (!xai?.on_demand_cap || xai.on_demand_cap <= 0) return 0
return Math.max(Math.min(((xai.on_demand_used || 0) / xai.on_demand_cap) * 100, 100), 0)
}
type GrokQuotaDisplay = GrokUpstreamMetadata & {
usage_percentage?: number
usage_limit?: number
@@ -2651,6 +2859,24 @@ function shouldAutoRefreshGeminiCliQuota(): boolean {
return false
}
function shouldAutoRefreshClaudeCodeQuota(): boolean {
if (provider.value?.provider_type !== 'claude_code') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasClaudeCodeQuotaDisplayData(key)) return true
const updatedAt = getClaudeCodeQuotaUpdatedAt(key)
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshKiroQuota(): boolean {
if (provider.value?.provider_type !== 'kiro') return false
const now = Math.floor(Date.now() / 1000)
@@ -2696,6 +2922,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
return false
}
function shouldAutoRefreshXaiQuota(): boolean {
if (provider.value?.provider_type !== 'xai') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasXaiQuotaDisplayData(key)) {
return true
}
const updatedAt = getXaiQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshWindsurfQuota(): boolean {
if (provider.value?.provider_type !== 'windsurf') return false
const now = Math.floor(Date.now() / 1000)
@@ -2824,12 +3072,14 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
if (refreshingQuota.value) return false
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false
if (providerType !== 'codex' && providerType !== 'claude_code' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok' && providerType !== 'xai') return false
// 检查是否需要刷新
let shouldRefresh = false
if (providerType === 'codex') {
shouldRefresh = shouldAutoRefreshCodexQuota()
} else if (providerType === 'claude_code') {
shouldRefresh = shouldAutoRefreshClaudeCodeQuota()
} else if (providerType === 'gemini_cli') {
shouldRefresh = shouldAutoRefreshGeminiCliQuota()
} else if (providerType === 'antigravity') {
@@ -2838,6 +3088,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'xai') {
shouldRefresh = shouldAutoRefreshXaiQuota()
} else if (providerType === 'windsurf') {
shouldRefresh = shouldAutoRefreshWindsurfQuota()
} else if (providerType === 'chatgpt_web') {
@@ -2848,6 +3100,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
let hadCachedQuota = false
if (providerType === 'codex') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
} else if (providerType === 'claude_code') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasClaudeCodeQuotaDisplayData(key))
} else if (providerType === 'gemini_cli') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key))
} else if (providerType === 'antigravity') {
@@ -2856,6 +3110,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'grok') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
} else if (providerType === 'xai') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasXaiQuotaDisplayData(key))
} else if (providerType === 'windsurf') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
@@ -3394,6 +3650,90 @@ interface AntigravityQuotaItem {
detail?: string
}
interface ClaudeCodeQuotaItem {
code: string
label: string
usedPercent: number
remainingPercent: number
resetSeconds: number | null
}
const CLAUDE_CODE_QUOTA_WINDOWS: Record<string, { labelKey: MessageKey, sortOrder: number }> = {
'5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 },
weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 },
weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 },
weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 },
}
function getClaudeCodeQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
const quota = getQuotaSnapshotForProvider(key, 'claude_code')
const quotaUpdatedAt = getQuotaSnapshotUpdatedAt(quota)
if (typeof quotaUpdatedAt === 'number') return quotaUpdatedAt
const updatedAt = Number(key.upstream_metadata?.claude_code?.updated_at ?? NaN)
return Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : undefined
}
function getClaudeCodeResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null {
return getQuotaSnapshotForProvider(key, 'claude_code')?.reset_credits
?? key.upstream_metadata?.claude_code?.reset_credits
?? null
}
function getClaudeCodeResetCreditAvailableCount(key: EndpointAPIKey): number | null {
return getCodexResetCreditAvailableCountFromSnapshot(getClaudeCodeResetCreditsDisplay(key))
}
function getVisibleClaudeCodeResetCreditItems(key: EndpointAPIKey) {
return getVisibleCodexResetCreditItemsFromSnapshot(
getClaudeCodeResetCreditsDisplay(key),
undefined,
5,
legacyT('Claude 重置机会'),
)
}
function hasClaudeCodeQuotaDisplayData(key: EndpointAPIKey): boolean {
return getClaudeCodeQuotaItems(key).length > 0
}
function getClaudeCodeQuotaItems(key: EndpointAPIKey): ClaudeCodeQuotaItem[] {
const quota = getQuotaSnapshotForProvider(key, 'claude_code')
const windows = quota?.windows
if (!quota || !Array.isArray(windows)) return []
return windows
.map((window) => {
const code = String(window?.code || '').trim().toLowerCase()
if (!code) return null
const usedPercent = getQuotaWindowUsedPercent(window)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (usedPercent === undefined && remainingPercent === undefined) return null
const normalizedUsedPercent = usedPercent !== undefined
? usedPercent
: Math.max(100 - (remainingPercent ?? 0), 0)
const normalizedRemainingPercent = remainingPercent !== undefined
? remainingPercent
: Math.max(100 - normalizedUsedPercent, 0)
const presentation = CLAUDE_CODE_QUOTA_WINDOWS[code]
return {
item: {
code,
label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'),
usedPercent: normalizedUsedPercent,
remainingPercent: normalizedRemainingPercent,
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
} satisfies ClaudeCodeQuotaItem,
sortOrder: presentation?.sortOrder ?? 9,
}
})
.filter((entry): entry is { item: ClaudeCodeQuotaItem, sortOrder: number } => entry !== null)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(entry => entry.item)
}
interface GeminiCliQuotaItem {
model: string
label: string
@@ -60,6 +60,9 @@
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -93,6 +96,9 @@
<SelectItem value="grok">
Grok
</SelectItem>
<SelectItem value="xai">
xAI
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
@@ -244,12 +250,18 @@
data-testid="codex-fingerprint-convergence-setting"
>
<div class="space-y-0.5">
<Label
for="codex-fingerprint-convergence"
class="text-sm font-medium"
>
{{ legacyT('Codex 指纹收敛') }}
</Label>
<div class="flex items-center gap-1">
<Label
for="codex-fingerprint-convergence"
class="text-sm font-medium"
>
{{ legacyT('Codex 指纹收敛(推荐开启)') }}
</Label>
<HelpHint
:label="legacyT('Codex 指纹收敛(推荐开启)')"
:text="legacyT('开启后,Aether 会根据当前账号身份为出站请求统一生成并写入以下标识,使同一 Codex 账号在上游看来始终是同一设备与会话:\n\n• 请求头:installation_id、session_id、thread_id、window_id、turn_id 等\n• 请求体(Responses 格式):client_metadata 与 prompt_cache_key 中的对应字段\n\n关闭时保持客户端原始透传,不做任何改写。建议开启以提升上游指纹稳定性与会话连续性。')"
/>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('统一同一 Codex 身份的设备与会话标识;关闭时保持现有透传行为。') }}
</p>
@@ -257,7 +269,7 @@
<Switch
id="codex-fingerprint-convergence"
:model-value="form.codex_fingerprint_convergence_enabled"
:aria-label="legacyT('Codex 指纹收敛')"
:aria-label="legacyT('Codex 指纹收敛(推荐开启)')"
@update:model-value="(v: boolean) => form.codex_fingerprint_convergence_enabled = v"
/>
</div>
@@ -274,7 +286,7 @@
{{ legacyT('Responses WebSocket 模式') }}
</Label>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ legacyT('允许此提供商处理标准 Responses API WebSocket 请求。仅在已验证兼容性后启用。') }}
{{ legacyT('允许此提供商处理标准 Responses API WebSocket 请求。') }}
</p>
</div>
<Switch
@@ -330,6 +342,7 @@ import {
Switch,
} from '@/components/ui'
import { Server, SquarePen } from 'lucide-vue-next'
import HelpHint from '@/components/common/HelpHint.vue'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { useI18n } from '@/i18n'
@@ -33,6 +33,32 @@ vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({
fetchModels: vi.fn(),
}),
}))
vi.mock('@/features/models/components/GlobalModelFormDialog.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'GlobalModelFormDialogStub',
props: {
open: { type: Boolean, default: false },
model: { type: Object, default: null },
zIndex: { type: Number, default: undefined },
},
emits: ['update:open', 'success'],
setup(props, { emit }) {
return () => {
if (!props.open) return null
return h('div', { 'data-testid': 'global-model-form-dialog-stub' }, [
h('button', {
type: 'button',
'data-testid': 'global-model-form-dialog-stub-success',
onClick: () => emit('success'),
}, 'submit'),
])
}
},
}),
}
})
vi.mock('@/components/ui/dialog/Dialog.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
@@ -84,6 +110,36 @@ afterEach(() => {
}
})
function createGlobalModel(id: string, name: string, displayName = name) {
return {
id,
name,
display_name: displayName,
is_active: true,
default_tiered_pricing: { tiers: [] },
created_at: '2026-01-01T00:00:00Z',
}
}
function createProviderModel(id: string, globalModelId: string) {
return {
id,
provider_id: 'provider-1',
global_model_id: globalModelId,
provider_model_name: globalModelId,
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
function visibleModelIds(root: HTMLElement): string[] {
return Array.from(root.querySelectorAll('[data-testid^="batch-assign-model-"]'))
.map(node => node.getAttribute('data-testid')?.replace('batch-assign-model-', '') ?? '')
.filter(Boolean)
}
describe('BatchAssignModelsDialog loading', () => {
it('loads model choices when lazily mounted in the open state', async () => {
const root = document.createElement('div')
@@ -107,4 +163,130 @@ describe('BatchAssignModelsDialog loading', () => {
expect(endpointMocks.getProviderModels).toHaveBeenCalledWith('provider-1')
expect(endpointMocks.getProviderKeys).toHaveBeenCalledWith('provider-1')
})
it('pins already associated models to the top of the list', async () => {
globalModelMocks.getGlobalModels.mockResolvedValue({
models: [
createGlobalModel('gm-zeta', 'zeta-model', 'Zeta'),
createGlobalModel('gm-alpha', 'alpha-model', 'Alpha'),
createGlobalModel('gm-mu', 'mu-model', 'Mu'),
],
total: 3,
})
endpointMocks.getProviderModels.mockResolvedValue([
createProviderModel('pm-mu', 'gm-mu'),
])
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(BatchAssignModelsDialog, {
open: true,
providerId: 'provider-1',
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
expect(visibleModelIds(root)).toEqual(['gm-mu', 'gm-alpha', 'gm-zeta'])
})
it('keeps selected matches pinned above other search results', async () => {
globalModelMocks.getGlobalModels.mockResolvedValue({
models: [
createGlobalModel('gm-beta', 'beta-flash', 'Beta Flash'),
createGlobalModel('gm-alpha', 'alpha-flash', 'Alpha Flash'),
createGlobalModel('gm-other', 'other-model', 'Other'),
],
total: 3,
})
endpointMocks.getProviderModels.mockResolvedValue([
createProviderModel('pm-beta', 'gm-beta'),
])
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(BatchAssignModelsDialog, {
open: true,
providerId: 'provider-1',
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
const search = root.querySelector('input') as HTMLInputElement
search.value = 'flash'
search.dispatchEvent(new Event('input', { bubbles: true }))
await settle()
expect(visibleModelIds(root)).toEqual(['gm-beta', 'gm-alpha'])
})
})
describe('BatchAssignModelsDialog create model entry', () => {
async function mountDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(BatchAssignModelsDialog, {
open: true,
providerId: 'provider-1',
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
return root
}
it('opens the unified model dialog from the create button', async () => {
const root = await mountDialog()
expect(root.querySelector('[data-testid="global-model-form-dialog-stub"]')).toBeNull()
const createButton = root.querySelector('[data-testid="batch-assign-create-model"]') as HTMLButtonElement
expect(createButton).toBeTruthy()
createButton.click()
await settle()
expect(root.querySelector('[data-testid="global-model-form-dialog-stub"]')).not.toBeNull()
})
it('refreshes the global model list after a model is created', async () => {
globalModelMocks.getGlobalModels
.mockResolvedValueOnce({
models: [createGlobalModel('gm-old', 'old-model', 'Old Model')],
total: 1,
})
.mockResolvedValueOnce({
models: [
createGlobalModel('gm-old', 'old-model', 'Old Model'),
createGlobalModel('gm-new', 'new-model', 'New Model'),
],
total: 2,
})
const root = await mountDialog()
expect(visibleModelIds(root)).toEqual(['gm-old'])
const createButton = root.querySelector('[data-testid="batch-assign-create-model"]') as HTMLButtonElement
createButton.click()
await settle()
const successButton = root.querySelector('[data-testid="global-model-form-dialog-stub-success"]') as HTMLButtonElement
expect(successButton).toBeTruthy()
successButton.click()
await settle()
expect(globalModelMocks.getGlobalModels).toHaveBeenCalledTimes(2)
expect(visibleModelIds(root)).toEqual(['gm-new', 'gm-old'])
})
})
@@ -69,6 +69,14 @@ describe('endpoint default paths', () => {
apiFormats,
})).toBe('/v1internal:{action}')
})
it('uses Anti Gravity v1internal paths for fixed Anti Gravity endpoints', () => {
expect(getDefaultEndpointPath({
apiFormat: 'gemini:generate_content',
providerType: 'antigravity',
baseUrl: 'https://daily-cloudcode-pa.googleapis.com',
apiFormats,
})).toBe('/v1internal:{action}')
})
it('keeps Codex Responses root path without duplicating /v1', () => {
expect(getDefaultEndpointPath({
@@ -54,6 +54,24 @@ describe('provider quota display components', () => {
unmount()
})
it('fills the remaining bar even when used percent is zero', () => {
const { root, unmount } = mount(ProviderQuotaProgressRow, {
label: '周额度',
usedPercent: 0,
remainingPercent: 86,
meterClass: 'text-green-600',
barClass: 'bg-green-500',
resetText: '5天0小时后重置',
})
expect(root.querySelector('[data-testid="provider-quota-progress-meter"]')?.textContent?.trim()).toBe('86.0%')
expect((root.querySelector('[data-testid="provider-quota-progress-bar"]') as HTMLElement).style.width).toBe('86%')
expect(root.textContent).toContain('周额度')
expect(root.querySelector('[data-testid="provider-quota-progress-reset"]')?.textContent).toBe('5天0小时后重置')
unmount()
})
it('renders section loading and updated state', () => {
const Probe = defineComponent({
setup() {
@@ -272,6 +272,7 @@ export function getVisibleCodexResetCreditItems(
snapshot: QuotaResetCreditsSnapshot | null | undefined,
nowUnixSecs = Math.floor(Date.now() / 1000),
limit = 5,
titleLabel = 'Codex 重置机会',
): CodexResetCreditDisplayItem[] {
const credits = snapshot?.credits
if (!snapshot || !Array.isArray(credits)) return []
@@ -295,7 +296,7 @@ export function getVisibleCodexResetCreditItems(
return {
...item,
displayKey,
title: `Codex 重置机会 ${displayKey}`,
title: `${titleLabel} ${displayKey}`,
} satisfies CodexResetCreditDisplayItem
})
}
@@ -182,6 +182,11 @@ export function getDefaultEndpointPath(params: {
return '/v1internal:{action}'
}
}
if (providerType === 'antigravity') {
if (normalizedApiFormat === 'gemini:generate_content') {
return '/v1internal:{action}'
}
}
if (providerType === 'vertex_ai') {
if (normalizedApiFormat === 'gemini:generate_content') {
return '/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}'
@@ -1386,6 +1386,7 @@ function formatAuthType(authType: string): string {
if (lowered === 'antigravity') return 'Antigravity OAuth'
if (lowered === 'kiro') return 'Kiro OAuth'
if (lowered === 'grok') return 'Grok OAuth'
if (lowered === 'xai') return 'xAI OAuth'
return authType
}
@@ -2,19 +2,58 @@
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<Checkbox
v-if="!isLoading && models.length > 0"
data-testid="models-tab-select-all"
class="shrink-0"
:checked="isAllSelected"
:indeterminate="isPartiallySelected"
:aria-label="selectAllLabel"
:title="selectAllLabel"
@update:checked="toggleSelectAll"
/>
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<span
v-if="selectedCount > 0"
class="text-xs text-muted-foreground tabular-nums"
>
已选 {{ selectedCount }} 个
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="selectedCount > 0"
variant="destructive"
size="sm"
class="h-8"
data-testid="models-tab-delete-selected"
:disabled="deletingSelected"
@click="confirmDeleteSelected"
>
<Loader2
v-if="deletingSelected"
class="w-3.5 h-3.5 mr-1.5 animate-spin"
/>
<Trash2
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
{{ deletingSelected ? '删除中...' : '删除选中' }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
</div>
</div>
</div>
@@ -44,10 +83,19 @@
<tr
v-for="model in paginatedModels"
:key="model.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
class="border-b border-border/40 last:border-b-0 transition-colors"
:class="isModelSelected(model.id) ? 'bg-primary/5' : 'hover:bg-muted/30'"
>
<td class="align-top px-4 py-3">
<div class="flex items-center gap-2.5">
<Checkbox
class="shrink-0"
:data-testid="`models-tab-row-checkbox-${model.id}`"
:checked="isModelSelected(model.id)"
:aria-label="`选择 ${model.global_model_display_name || model.provider_model_name}`"
@click.stop
@update:checked="checked => toggleModelSelection(model.id, checked)"
/>
<!-- 状态指示灯 -->
<div
class="w-2 h-2 rounded-full shrink-0"
@@ -171,7 +219,12 @@
v-if="shouldPaginateModels"
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
>
<span>共 {{ sortedModels.length }} 个模型</span>
<span>
共 {{ sortedModels.length }} 个模型
<template v-if="selectedCount > 0">
· 已选 {{ selectedCount }} 个
</template>
</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
@@ -251,18 +304,21 @@
import { ref, computed, watch } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { useModelTest } from '@/composables/useModelTest'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import { Box, Edit, Layers, Power, Copy, Loader2, Play, Trash2 } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useI18n } from '@/i18n'
import { sortResolutionEntries } from '@/utils/form'
import {
type Model,
type ProviderEndpoint,
} from '@/api/endpoints'
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { deleteModel, updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
@@ -296,7 +352,9 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const { legacyT } = useI18n()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
@@ -305,6 +363,8 @@ const modelTest = useModelTest({ providerId: () => props.provider.id })
const localLoading = ref(false)
const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const selectedIds = ref<Set<string>>(new Set())
const deletingSelected = ref(false)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestHeadersDraft = ref('')
@@ -385,6 +445,16 @@ const sortedModels = computed(() => {
})
})
const selectedCount = computed(() => selectedIds.value.size)
const isAllSelected = computed(() => (
sortedModels.value.length > 0
&& sortedModels.value.every(model => selectedIds.value.has(model.id))
))
const isPartiallySelected = computed(() => (
selectedCount.value > 0 && !isAllSelected.value
))
const selectAllLabel = computed(() => (isAllSelected.value ? '取消全选' : '全选'))
// ===== 模型列表智能分页 =====
const modelsListRef = ref<HTMLElement | null>(null)
const {
@@ -519,6 +589,71 @@ function openBatchAssignDialog() {
emit('batchAssign')
}
function isModelSelected(modelId: string): boolean {
return selectedIds.value.has(modelId)
}
function toggleModelSelection(modelId: string, checked?: boolean) {
const next = new Set(selectedIds.value)
const shouldSelect = checked ?? !next.has(modelId)
if (shouldSelect) {
next.add(modelId)
} else {
next.delete(modelId)
}
selectedIds.value = next
}
function toggleSelectAll(checked: boolean) {
selectedIds.value = checked
? new Set(sortedModels.value.map(model => model.id))
: new Set()
}
function pruneMissingSelection(validIds: Set<string>) {
if (selectedIds.value.size === 0) return
const next = new Set([...selectedIds.value].filter(id => validIds.has(id)))
if (next.size !== selectedIds.value.size) {
selectedIds.value = next
}
}
async function confirmDeleteSelected() {
const ids = Array.from(selectedIds.value)
if (ids.length === 0 || deletingSelected.value) return
const confirmed = await confirmDanger(
legacyT(`确定删除选中的 ${ids.length} 个模型吗?\n\n此操作不可撤销。`),
legacyT('批量删除模型'),
)
if (!confirmed) return
deletingSelected.value = true
try {
const results = await Promise.allSettled(
ids.map(id => deleteModel(props.provider.id, id)),
)
const successCount = results.filter(result => result.status === 'fulfilled').length
const failedCount = results.length - successCount
if (successCount > 0) {
showSuccess(legacyT(`成功删除 ${successCount} 个模型`))
}
if (failedCount > 0) {
showError(legacyT(`${failedCount} 个模型删除失败`), legacyT('部分失败'))
}
selectedIds.value = new Set()
if (successCount > 0) {
emit('refresh')
}
} catch (err: unknown) {
showError(parseApiError(err, '批量删除失败'), '错误')
} finally {
deletingSelected.value = false
}
}
// 切换模型启用状态
async function toggleModelActive(model: Model) {
if (togglingModelId.value) return
@@ -756,6 +891,14 @@ watch(
modelTestProviderKeys.value = []
modelTestKeysLoadedProviderId.value = null
selectedTestKeyIds.value = []
selectedIds.value = new Set()
},
)
watch(
() => models.value.map(model => model.id),
(ids) => {
pruneMissingSelection(new Set(ids))
},
)
@@ -0,0 +1,265 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue'
import ModelsTab from '../ModelsTab.vue'
import { createI18n } from '@/i18n'
import type { Model, ProviderWithEndpointsSummary } from '@/api/endpoints'
const modelMocks = vi.hoisted(() => ({
deleteModel: vi.fn(),
updateModel: vi.fn(),
}))
const confirmMocks = vi.hoisted(() => ({
confirmDanger: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/api/endpoints/models', () => modelMocks)
vi.mock('@/api/endpoints/keys', () => ({
getProviderKeys: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => confirmMocks,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMocks,
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard: vi.fn() }),
}))
vi.mock('@/composables/useModelTest', () => ({
useModelTest: () => ({
testing: { value: false },
dialogOpen: { value: false },
testResult: { value: null },
testMode: { value: 'global' },
testTrace: { value: null },
requestId: { value: null },
resetState: vi.fn(),
startTest: vi.fn(),
stopPolling: vi.fn(),
}),
}))
vi.mock('../ModelTestDialog.vue', () => ({
default: defineComponent({
name: 'ModelTestDialogStub',
setup: () => () => null,
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function createProvider(id = 'provider-1'): ProviderWithEndpointsSummary {
return {
id,
name: 'OpenAI Responses',
provider_type: 'custom',
provider_priority: 1,
keep_priority_on_conversion: false,
enable_format_conversion: false,
is_active: true,
total_endpoints: 1,
active_endpoints: 1,
total_keys: 1,
active_keys: 1,
total_models: 2,
active_models: 2,
global_model_ids: [],
avg_health_score: 1,
unhealthy_endpoints: 0,
api_formats: ['openai:chat'],
endpoint_health_details: [],
ops_configured: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
function createModel(overrides: Partial<Model> = {}): Model {
return {
id: 'model-1',
provider_id: 'provider-1',
global_model_id: 'gm-1',
provider_model_name: 'deepseek-v4-flash',
global_model_name: 'deepseek-v4-flash',
global_model_display_name: 'DeepSeek V4 Flash',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
const sampleModels = [
createModel(),
createModel({
id: 'model-2',
provider_model_name: 'kimi-k3',
global_model_name: 'kimi-k3',
global_model_display_name: 'Kimi K3',
}),
]
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountTab(options?: {
models?: Model[]
provider?: ProviderWithEndpointsSummary
}) {
const root = document.createElement('div')
document.body.appendChild(root)
const state = reactive({
models: options?.models ?? sampleModels,
provider: options?.provider ?? createProvider(),
})
const onRefresh = vi.fn()
const onBatchAssign = vi.fn()
const app = createApp(defineComponent({
setup() {
return () => h(ModelsTab, {
provider: state.provider,
models: state.models,
endpoints: [],
onRefresh,
onBatchAssign,
})
},
}))
app.use(createI18n())
app.mount(root)
mountedApps.push({ app, root })
return { root, state, onRefresh }
}
beforeEach(() => {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
modelMocks.deleteModel.mockReset()
modelMocks.deleteModel.mockResolvedValue({ message: 'ok' })
modelMocks.updateModel.mockReset()
confirmMocks.confirmDanger.mockReset()
confirmMocks.confirmDanger.mockResolvedValue(true)
toastMocks.success.mockReset()
toastMocks.error.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('ModelsTab batch delete', () => {
it('keeps delete selected hidden until a model is checked', async () => {
const { root } = mountTab()
await settle()
expect(root.querySelector('[data-testid="models-tab-select-all"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
})
it('selects all, shows a partial state, and deletes after confirmation', async () => {
const { root, onRefresh } = mountTab()
await settle()
const selectAll = root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement
selectAll.click()
await settle()
expect(selectAll.checked).toBe(true)
expect(selectAll.indeterminate).toBe(false)
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]') as HTMLInputElement).checked).toBe(true)
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).checked).toBe(true)
expect(root.textContent).toContain('已选 2 个')
;(root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).click()
await settle()
expect(selectAll.checked).toBe(false)
expect(selectAll.indeterminate).toBe(true)
expect(root.textContent).toContain('已选 1 个')
const deleteButton = root.querySelector('[data-testid="models-tab-delete-selected"]') as HTMLButtonElement
expect(deleteButton.textContent).toContain('删除选中')
deleteButton.click()
await settle()
expect(confirmMocks.confirmDanger).toHaveBeenCalledWith(
'确定删除选中的 1 个模型吗?\n\n此操作不可撤销。',
'批量删除模型',
)
expect(modelMocks.deleteModel).toHaveBeenCalledTimes(1)
expect(modelMocks.deleteModel).toHaveBeenCalledWith('provider-1', 'model-1')
expect(toastMocks.success).toHaveBeenCalledWith('成功删除 1 个模型')
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
expect(selectAll.checked).toBe(false)
expect(selectAll.indeterminate).toBe(false)
})
it('does not delete when confirmation is cancelled', async () => {
confirmMocks.confirmDanger.mockResolvedValue(false)
const { root, onRefresh } = mountTab()
await settle()
;(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]') as HTMLInputElement).click()
await settle()
;(root.querySelector('[data-testid="models-tab-delete-selected"]') as HTMLButtonElement).click()
await settle()
expect(modelMocks.deleteModel).not.toHaveBeenCalled()
expect(onRefresh).not.toHaveBeenCalled()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeTruthy()
})
it('drops stale selection when the current provider list refreshes', async () => {
const { root, state } = mountTab()
await settle()
const selectAll = root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement
selectAll.click()
await settle()
expect(root.textContent).toContain('已选 2 个')
state.models = [sampleModels[1]]
await settle()
expect(root.textContent).toContain('已选 1 个')
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]')).toBeNull()
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).checked).toBe(true)
})
it('clears selection when switching providers', async () => {
const { root, state } = mountTab()
await settle()
;(root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement).click()
await settle()
expect(root.textContent).toContain('已选 2 个')
state.provider = createProvider('provider-2')
await settle()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
expect((root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement).checked).toBe(false)
})
})
@@ -39,10 +39,12 @@ const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'vertex_ai',
'antigravity',
'kiro',
'xai',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
'xai',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
@@ -16,6 +16,12 @@ describe('providerTypeUtils', () => {
expect(isKeyManagedProviderType('grok')).toBe(false)
})
it('treats xAI as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('xai')).toBe(true)
expect(isOAuthAccountProviderType('xAI')).toBe(true)
expect(isKeyManagedProviderType('xai')).toBe(false)
})
it('treats Windsurf as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
'antigravity',
'kiro',
'grok',
'xai',
'windsurf',
])
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingSchedulingPolicyEditor from '../components/RoutingSchedulingPolicyEditor.vue'
import {
createEmptyRoutingGroupConfig,
getModelPolicy,
type RoutingGroupConfig,
} from '../utils/routingPolicy'
import { createSchedulingPolicy, writeSchedulingPolicies } from '../utils/schedulingPolicies'
vi.mock('@/api/endpoints', () => ({ getProvidersSummary: vi.fn() }))
const globalModels = ['a', 'b', 'c'].map(name => ({
id: `id-${name}`, name: `model-${name}`, display_name: `模型 ${name.toUpperCase()}`,
})) as GlobalModelResponse[]
const providerSources = [
{ id: 'provider-a', name: '提供商 A', global_model_ids: ['id-a'] },
{ id: 'provider-b', name: '提供商 B', global_model_ids: ['id-b'] },
{ id: 'provider-shared', name: '共享提供商', global_model_ids: ['id-a', 'id-b'] },
{ id: 'provider-other', name: '无关提供商', global_model_ids: ['id-other'] },
].map((provider, index) => ({
...provider,
provider_priority: index,
is_active: true,
api_formats: ['openai:chat'],
})) as ProviderWithEndpointsSummary[]
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(selectedModels?: string[], initialModels = globalModels) {
const initial = createEmptyRoutingGroupConfig()
const config = ref(selectedModels === undefined ? initial : writeSchedulingPolicies(initial, [{
...createSchedulingPolicy(initial, 'selected'),
models: selectedModels,
}]))
const models = ref(initialModels)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingSchedulingPolicyEditor, {
config: config.value,
globalModels: models.value,
'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value },
}),
})
app.mount(root)
mounted.push({ app, root })
return { root, config, models }
}
function providerNames(root: HTMLElement): string[] {
return [...root.querySelectorAll('[draggable="true"] .font-medium')]
.map(element => element.textContent?.trim() ?? '')
}
async function clickButton(root: HTMLElement, label: string) {
const button = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(element => element.getAttribute('aria-label') === label || element.textContent?.trim() === label)
expect(button, `Missing button: ${label}`).toBeTruthy()
button!.click()
await nextTick()
}
async function toggleModel(root: HTMLElement, name: string) {
if (!root.querySelector('[aria-label="全局模型选择列表"]')) {
await clickButton(root, '选择适用模型')
}
const checkbox = root.querySelector<HTMLInputElement>(`[aria-label="选择模型 ${name}"]`)
expect(checkbox).toBeTruthy()
checkbox!.click()
await nextTick()
}
beforeEach(() => {
vi.mocked(getProvidersSummary).mockReset()
vi.mocked(getProvidersSummary).mockResolvedValue({
items: providerSources, total: providerSources.length, page: 1, page_size: 9999,
})
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('scheduling provider filtering', () => {
it('filters providers after choosing a global model and restores all-model mode', async () => {
const { root } = mountEditor()
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4))
await clickButton(root, '区分模型')
expect(providerNames(root)).toEqual([])
await toggleModel(root, 'model-a')
await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商']))
await clickButton(root, '全部模型')
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4))
})
it('shows the union for multiple models once and updates immediately when deselected', async () => {
const { root } = mountEditor(['model-a'])
await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商']))
await toggleModel(root, 'model-b')
expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商'])
await toggleModel(root, 'model-a')
expect(providerNames(root)).toEqual(['提供商 B', '共享提供商'])
expect(getProvidersSummary).toHaveBeenCalledTimes(1)
await toggleModel(root, 'model-b')
expect(providerNames(root)).toEqual([])
expect(root.textContent).not.toContain('提供商排序')
})
it('keeps the shared ranking attached to every selected model after filtering', async () => {
const { root, config } = mountEditor(['model-a', 'model-b'])
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3))
const row = [...root.querySelectorAll<HTMLElement>('[draggable="true"]')]
.find(element => element.textContent?.includes('共享提供商'))!
const input = row.querySelector<HTMLInputElement>('input[type="number"]')!
input.value = '7'
input.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
for (const model of ['model-a', 'model-b']) {
expect(getModelPolicy(config.value, model).provider_priority_overrides).toEqual({ 'provider-shared': 7 })
}
expect(getModelPolicy(config.value, '*').provider_priority_overrides).toEqual({})
expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商'])
expect(getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('waits for global model IDs without briefly displaying all providers', async () => {
const { root, models } = mountEditor(['model-a'], [])
await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider'))
expect(providerNames(root)).toEqual([])
models.value = globalModels
await nextTick()
expect(providerNames(root)).toEqual(['提供商 A', '共享提供商'])
expect(getProvidersSummary).toHaveBeenCalledTimes(1)
})
it.each(['model-c', 'missing-model'])('shows an empty list when no providers match %s', async model => {
const { root } = mountEditor([model])
await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider'))
expect(providerNames(root)).toEqual([])
})
it('drops hidden providers from the temporary multiselection when models change', async () => {
const { root } = mountEditor(['model-a', 'model-b'])
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3))
await clickButton(root, '多选')
const checkbox = root.querySelector<HTMLInputElement>('[aria-label="选择 提供商 A"]')!
checkbox.click()
await nextTick()
expect(checkbox.checked).toBe(true)
await toggleModel(root, 'model-a')
expect(providerNames(root)).toEqual(['提供商 B', '共享提供商'])
await toggleModel(root, 'model-a')
expect(root.querySelector<HTMLInputElement>('[aria-label="选择 提供商 A"]')!.checked).toBe(false)
})
})
@@ -369,6 +369,7 @@ const props = defineProps<{
config: RoutingGroupConfig
model?: string
modelId?: string
providerModelIds?: string[]
priorityMode?: RoutingPriorityMode
schedulingMode?: RoutingSchedulingMode
showPriorityMode?: boolean
@@ -445,7 +446,10 @@ const poolProviderIds = computed(() => {
const providerRows = computed<ProviderPriorityRow[]>(() => {
const overrides = targetModelPolicy.value.provider_priority_overrides
// 多选模型取提供商并集;空数组表示模型尚未解析,不能回退到全部提供商。
const modelIds = props.providerModelIds === undefined ? null : new Set(props.providerModelIds)
return providers.value
.filter(provider => !modelIds || provider.global_model_ids?.some(id => modelIds.has(id)))
.map(provider => ({
id: provider.id,
name: provider.name,
@@ -178,6 +178,7 @@
</div>
<RoutingPriorityPolicyEditor
:config="schedulingPolicyEditorConfig(config, entry)"
:provider-model-ids="entry.scope === 'selected' ? providerModelIds(entry) : undefined"
:show-priority-mode="false"
:show-scheduling-mode="false"
subtitle="所选模型共用此排序,仅对各模型可用的候选生效"
@@ -294,6 +295,11 @@ function otherModelOwners(entryId: string): Record<string, number> {
: []))
}
function providerModelIds(entry: SchedulingPolicy): string[] {
const names = new Set(entry.models)
return props.globalModels.filter(model => names.has(model.name)).map(model => model.id)
}
function publish(): void {
emit('update:config', writeSchedulingPolicies({
...props.config,
@@ -36,6 +36,21 @@
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
{{ getFinalStatusLabel(computedFinalStatus) }}
</Badge>
<!-- 被跳过的候选可以收起,避免长时间线上排查时干扰视线 -->
<button
v-if="skippedTraceCandidates.length > 0"
type="button"
class="skipped-toggle"
:class="{ active: showSkippedCandidates }"
:aria-pressed="showSkippedCandidates"
:title="showSkippedCandidates
? '隐藏被跳过的候选'
: '被跳过的候选在调度阶段就被判定本次不可用,从未向上游发起请求'"
data-timeline-skipped-toggle
@click="showSkippedCandidates = !showSkippedCandidates"
>
{{ showSkippedCandidates ? '隐藏' : '显示' }}被跳过候选({{ skippedTraceCandidates.length }})
</button>
</div>
<div class="text-sm text-muted-foreground">
{{ formatLatency(totalTraceLatency) }}
@@ -449,7 +464,7 @@
v-if="currentAttemptSkipReasonDisplay"
class="skip-reason"
>
<span class="reason-label">跳过原因</span>
<span class="reason-label">{{ currentAttemptSkipReasonLabel }}</span>
<span class="reason-content">
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
<span
@@ -586,6 +601,7 @@ import { resolveTimelineFinalStatus } from '../utils/status'
import { useClipboard } from '@/composables/useClipboard'
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
import { formatCandidateSkipReason } from '../utils/skipReason'
import {
buildPoolGroupVisibleAttempts,
buildPoolParticipatedCandidates,
@@ -841,13 +857,30 @@ const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): numbe
}
// 候选时间线(按调度顺序排序;lazy 加载的跳过候选通常没有 started_at)
const rawTimeline = computed<CandidateRecord[]>(() => {
const allTraceCandidates = computed<CandidateRecord[]>(() => {
if (!trace.value) return []
return [...trace.value.candidates]
.filter(c => TIMELINE_STATUS.includes(c.status))
.sort(compareBySchedulingOrder)
})
/**
* 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。
*
* 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案,
* 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。
*/
const showSkippedCandidates = ref(true)
const skippedTraceCandidates = computed<CandidateRecord[]>(
() => allTraceCandidates.value.filter(c => c.status === 'skipped'),
)
const rawTimeline = computed<CandidateRecord[]>(() => {
if (showSkippedCandidates.value) return allTraceCandidates.value
return allTraceCandidates.value.filter(c => c.status !== 'skipped')
})
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
const metadata = props.requestMetadata
@@ -1233,7 +1266,12 @@ const latestTraceAttemptForState = computed<CandidateRecord | null>(() => {
const candidates = rawTimeline.value
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const candidate = candidates[index]
if (candidate.status !== 'available' && candidate.status !== 'unused') {
// 跳过/未使用候选没有状态码与错误信息,不能代表本次请求的最终结果。
if (
candidate.status !== 'available' &&
candidate.status !== 'unused' &&
candidate.status !== 'skipped'
) {
return candidate
}
}
@@ -1418,35 +1456,30 @@ const currentAttemptKeyFormatsDisplay = computed(() => {
.map(format => formatApiFormat(format))
.join(' / ')
})
const SKIP_REASON_LABELS: Record<string, string> = {
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
pool_key_lease_busy: '池内账号正被其他请求占用',
provider_concurrency_limit_reached: '上游提供商并发已达上限',
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
provider_request_body_build_failed: '上游请求体转换失败',
provider_request_body_missing: '无法构建上游请求体',
}
const currentAttemptSkipReasonDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt?.skip_reason) return ''
const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason]
if (skipReasonLabel) {
return skipReasonLabel
// transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。
if (attempt.skip_reason === 'transport_unsupported') {
const transportDiagnostics = resolveTransportDiagnostics(attempt)
const requestPair = extractObject(transportDiagnostics?.request_pair)
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
? requestPair.transport_unsupported_reason.trim()
: ''
return detailedReason || formatCandidateSkipReason(attempt.skip_reason)
}
if (attempt.skip_reason !== 'transport_unsupported') {
return attempt.skip_reason
}
return formatCandidateSkipReason(attempt.skip_reason)
})
const transportDiagnostics = resolveTransportDiagnostics(attempt)
const requestPair = extractObject(transportDiagnostics?.request_pair)
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
? requestPair.transport_unsupported_reason.trim()
: ''
return detailedReason || attempt.skip_reason
/**
* 区分"跳过"与"尝试失败":被跳过的候选从未发到上游,
* 不写清楚容易被误读成"上游返回了错误"。
*/
const currentAttemptSkipReasonLabel = computed(() => {
const status = currentAttempt.value?.status
return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因'
})
const currentAttemptFailureDiagnostic = computed<{
@@ -2212,7 +2245,9 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
// 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据,
// 是否显示由前端开关控制,避免再发一次请求。
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -2798,6 +2833,30 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
cursor: not-allowed;
}
/* 「显示/隐藏被跳过候选」开关:低调的描边小按钮,不抢主状态徽标的视觉重心 */
.skipped-toggle {
padding: 0.125rem 0.5rem;
border: 1px dashed hsl(var(--border));
border-radius: 9999px;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.5;
cursor: pointer;
transition: all 0.15s ease;
}
.skipped-toggle:hover {
color: hsl(var(--foreground));
border-color: hsl(var(--muted-foreground) / 0.5);
}
.skipped-toggle.active {
border-style: solid;
border-color: hsl(var(--primary) / 0.5);
color: hsl(var(--primary));
}
.nav-info {
font-size: 0.8rem;
font-weight: 500;
@@ -1016,6 +1016,7 @@ const emit = defineEmits<{
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1158,6 +1159,7 @@ type HeaderModelTextField =
| 'model'
| 'target_model'
| 'model_version'
| 'response_model'
| 'request_type'
| 'requested_reasoning_effort'
| 'reasoning_effort'
@@ -1166,6 +1168,7 @@ type HeaderModelTextField =
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
'target_model',
'response_model',
'reasoning_effort',
'service_tier',
'actual_service_tier',
@@ -1252,6 +1255,7 @@ watch(
props.summaryRecord?.model,
props.summaryRecord?.target_model,
props.summaryRecord?.model_version,
props.summaryRecord?.response_model,
props.summaryRecord?.request_type,
props.summaryRecord?.requested_reasoning_effort,
props.summaryRecord?.reasoning_effort,
@@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
const responseModel = resolveHeaderModelTextField('response_model', nextDetail)
emit('requestState', {
id,
@@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
...(reasoningEffort ? { reasoningEffort } : {}),
...(serviceTier ? { serviceTier } : {}),
...(actualServiceTier ? { actualServiceTier } : {}),
...(responseModel ? { responseModel } : {}),
errorMessage: nextDetail.error_message ?? undefined,
updatedAt: nextDetail.updated_at ?? undefined,
})
@@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => {
return {
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
target_model: resolveHeaderModelTextField('target_model', currentDetail),
model_version: resolveHeaderModelTextField('model_version', currentDetail),
response_model: resolveHeaderModelTextField('response_model', currentDetail),
request_type: resolveHeaderModelTextField('request_type', currentDetail),
requested_reasoning_effort: resolveHeaderModelTextField(
'requested_reasoning_effort',
@@ -8,19 +8,30 @@
: undefined"
>
<div
class="flex min-w-0 max-w-full items-center gap-1"
:class="[modelRowClass, actualModel ? 'flex-wrap' : '']"
class="flex min-w-0 max-w-full flex-wrap items-center gap-1"
:class="modelRowClass"
>
<span
class="min-w-0 truncate"
:class="modelClass"
data-usage-model-source
>{{ record.model }}</span>
<template v-if="actualModel">
<span
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground"
data-usage-model-target
><span class="mr-1">-&gt;</span>{{ actualModel }}</span>
<template v-if="hasModelFacts">
<div
class="order-last basis-full flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-muted-foreground"
data-usage-model-facts
>
<span
v-if="mappingModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-mapping
><span class="mr-1 text-[10px] text-muted-foreground/70">映射模型</span>{{ mappingModel }}</span>
<span
v-if="responseModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-response
><span class="mr-1 text-[10px] text-muted-foreground/70">响应模型</span>{{ responseModel }}</span>
</div>
</template>
<template v-if="!shouldStackBadges">
<Badge
@@ -29,7 +40,7 @@
:data-usage-model-badge="badge.key"
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
:variant="badge.variant"
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
class="h-4 max-w-full shrink-0 truncate rounded-full px-1.5 text-[10px] leading-4"
:class="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
@@ -51,7 +62,7 @@
:data-usage-model-badge="badge.key"
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
:variant="badge.variant"
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
class="h-4 max-w-full shrink-0 truncate rounded-full px-1.5 text-[10px] leading-4"
:class="badge.className"
:title="badge.title"
:aria-label="badge.ariaLabel"
@@ -69,7 +80,7 @@ import { Badge } from '@/components/ui'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatServiceTierFact } from '../utils/service-tier'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'service-tier' | 'cyber' | 'reasoning_tokens'
interface ModelBadgePresentation {
key: ModelBadgeKey
@@ -83,7 +94,7 @@ interface ModelBadgePresentation {
interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -113,15 +124,20 @@ const props = withDefaults(defineProps<{
showReasoningBadge: true,
})
const actualModel = computed(() => {
const mappingModel = computed(() => {
const targetModel = normalizeText(props.record.target_model)
if (targetModel && targetModel !== props.record.model) return targetModel
const modelVersion = normalizeText(props.record.model_version)
if (modelVersion && modelVersion !== props.record.model) return modelVersion
if (targetModel && targetModel !== normalizeText(props.record.model)) return targetModel
return null
})
const responseModel = computed(() => {
const response = normalizeText(props.record.response_model)
if (response && response !== normalizeText(props.record.model)) return response
return null
})
const hasModelFacts = computed(() => mappingModel.value !== null || responseModel.value !== null)
const reasoningLabel = computed(() => {
const requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort)
@@ -154,14 +170,16 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
}
if (props.showServiceTierBadge && formatServiceTierFact(props.record.service_tier) === 'Fast') {
const serviceTier = formatServiceTierFact(props.record.service_tier)
if (props.showServiceTierBadge && serviceTier
&& !['auto', 'default', 'standard'].includes(serviceTier.toLowerCase())) {
badges.push({
key: 'fast',
label: 'Fast',
key: serviceTier === 'Fast' ? 'fast' : 'service-tier',
label: serviceTier,
variant: 'outline-transparent',
className: 'text-blue-500 dark:text-blue-300',
title: '上游请求档位:Fast\n计费档位:Fast',
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
title: `上游请求档位:${serviceTier}\n计费档位:${serviceTier}`,
ariaLabel: `上游请求档位:${serviceTier},计费档位:${serviceTier}`,
})
}
@@ -189,7 +207,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
const shouldStackBadges = computed(() => (
actualModel.value === null && modelBadges.value.length >= 3
!hasModelFacts.value && modelBadges.value.length >= 3
))
function normalizeText(value: string | null | undefined): string | null {
@@ -149,32 +149,14 @@
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部类型
</SelectItem>
<SelectItem value="stream">
HTTP 流式
</SelectItem>
<SelectItem value="standard">
HTTP 标准
</SelectItem>
<SelectItem value="websocket">
WebSocket (WS)
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
<!-- 手机与桌面共用状态选项,避免新增筛选时漏掉手机入口。 -->
<SelectItem
v-for="option in statusFilterOptions"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -362,6 +344,28 @@
</span>
<span class="shrink-0 text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
<!-- 手机与桌面保持相同的标记优先级:发生故障转移时优先显示转移标记。 -->
<Shuffle
v-if="record.has_fallback"
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
title="此请求发生了重试"
aria-label="发生重试"
/>
</div>
<!-- 第四行:性能指标 -->
@@ -791,6 +795,18 @@
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<!--
被跳过的更高优先级候选:与"故障转移"区分开。
它表示候选在调度阶段就被排除、请求从未发出,因此不会有任何上游报错,
是"无报错却换了提供商"这一现象的直接解释。
-->
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
@@ -1093,7 +1109,7 @@ import {
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { Ban, EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
@@ -1115,6 +1131,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import { formatServiceTierFact } from '../utils/service-tier'
import { formatCandidateSkipReason } from '../utils/skipReason'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
import type { DateRangeParams, UsageRecord } from '../types'
@@ -1370,6 +1387,8 @@ const statusFilterOptions: FilterOption[] = [
{ value: 'cancelled', label: '已取消' },
{ value: 'has_retry', label: '发生重试' },
{ value: 'has_fallback', label: '发生转移' },
// 与"发生转移"区分:候选在调度阶段就被排除,请求从未发出(无上游报错)
{ value: 'has_skipped_candidate', label: '有候选被调度跳过' },
]
const timeRangeModel = computed({
@@ -1602,6 +1621,23 @@ function shouldShowFormatConversion(record: UsageRecord): boolean {
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
}
// 被跳过候选的提示文案:把"调度阶段排除"和"上游失败"讲清楚,
// 避免用户把"无报错换提供商"误判成调度 bug 或提供商静默出错。
function skippedCandidateTooltip(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? [])
.map(formatCandidateSkipReason)
.filter((reason, index, all) => reason && all.indexOf(reason) === index)
const header = '本次有候选在调度阶段被跳过,请求未发往该候选(因此不会有上游报错)'
if (!reasons.length) return header
return `${header}\n跳过原因:${reasons.join(';')}`
}
function skippedCandidateAriaLabel(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? []).map(formatCandidateSkipReason)
return reasons.length ? `有候选被调度跳过:${reasons.join(';')}` : '有候选被调度跳过'
}
// 获取 API 格式的 tooltip(包含转换信息)
function getApiFormatTooltip(record: UsageRecord): string {
if (!record.api_format) {
@@ -1620,20 +1656,6 @@ function getApiFormatTooltip(record: UsageRecord): string {
return displayFormat
}
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
function getActualModel(record: UsageRecord): string | null {
// 优先显示模型映射
if (record.target_model && record.target_model !== record.model) {
return record.target_model
}
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
if (record.model_version && record.model_version !== record.model) {
return record.model_version
}
return null
}
function getReasoningEffort(record: UsageRecord): string | null {
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
@@ -1708,15 +1730,18 @@ function getServiceTierTitle(record: UsageRecord): string {
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
return `${record.model}${suffix}`
const requestModel = record.model.trim()
const mappingModel = record.target_model?.trim()
const responseModel = record.response_model?.trim()
return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
}
</script>
@@ -425,6 +425,56 @@ describe('HorizontalRequestTimeline', () => {
expect(nodeDots[2].classList.contains('status-pending')).toBe(true)
})
it('shows a skipped candidate with a Chinese reason and can collapse it', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-skipped-rpm',
provider_id: 'provider-1',
provider_name: 'Provider 1',
key_id: 'key-1',
key_name: 'Key 1',
candidate_index: 0,
status: 'skipped',
skip_reason: 'key_rpm_exhausted',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-success-2',
provider_id: 'provider-2',
provider_name: 'Provider 2',
key_id: 'key-2',
key_name: 'Key 2',
candidate_index: 1,
status: 'success',
}),
])
const root = mountTimeline(trace)
await nextTick()
// 被跳过的候选默认可见:它是"为什么没用这个提供商"的答案
expect([...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim()))
.toEqual(['Provider 1', 'Provider 2'])
// 点击第一个节点查看详情,应看到中文跳过原因
root.querySelector<HTMLElement>('.minimal-node-group')?.click()
await nextTick()
expect(root.textContent).toContain('密钥本分钟请求数已达上限')
// 必须点明"没有向上游发起请求",否则会被误读成上游报错
expect(root.textContent).toContain('未向上游发起请求')
// 开关可把跳过候选整体收起
const toggle = root.querySelector<HTMLButtonElement>('.skipped-toggle')
expect(toggle).not.toBeNull()
toggle?.click()
await nextTick()
expect([...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim()))
.toEqual(['Provider 2'])
})
it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => {
const trace = buildTrace([
buildCandidate({
@@ -309,26 +309,31 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
})
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
it.each([
{ tier: 'priority', label: 'Fast', badgeKey: 'fast' },
{ tier: 'ultrafast', label: 'ultrafast', badgeKey: 'service-tier' },
{ tier: 'future-tier', label: 'future-tier', badgeKey: 'service-tier' },
])('shows mapping, response model, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-risk-demo',
request_id: 'req_usage-cyber-risk-demo',
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
request_body: {
model: 'gpt-5',
reasoning: { effort: 'xhigh' },
},
service_tier: 'priority',
service_tier: tier,
// A response-side tier must not be used for the Fast badge or billing.
actual_service_tier: 'default',
provider_request_body: {
model: 'gpt-5.1',
reasoning: { effort: 'max' },
service_tier: 'priority',
service_tier: tier,
},
status: 'failed',
status_code: 400,
@@ -369,10 +374,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent?.trim())
.toBe('Fast')
expect(document.body.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent?.trim())
.toBe(label)
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
const modelLayout = document.body.querySelector(
@@ -380,14 +383,15 @@ describe('RequestDetailDrawer settlement pricing', () => {
)
const modelRow = modelLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
.toBe(true)
expect(modelRow?.classList.contains('flex-wrap')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent)
.toContain('gpt-5.2')
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
.toContain('Fast')
expect(modelRow?.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent)
.toContain(label)
expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull()
@@ -395,7 +399,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
.toEqual(['上游请求层级', '计费层级'])
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
.toEqual(['Fast', 'Fast'])
.toEqual([label, label])
})
})
@@ -460,7 +464,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
@@ -571,8 +575,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1')
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent?.trim())
.toBe('映射模型gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -583,7 +587,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
})
})
it('uses detail model_version when the lightweight summary has null model facts', async () => {
it('does not use legacy model_version as a response-model fallback', async () => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-version-summary',
@@ -626,8 +630,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1-2026-07-17')
expect(document.body.querySelector('[data-usage-model-response]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
})
})
@@ -687,7 +691,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
}
await nextTick()
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -92,6 +92,7 @@ vi.mock('lucide-vue-next', async () => {
EyeOff: Icon,
Search: Icon,
Shuffle: Icon,
Ban: Icon,
ChevronDown: Icon,
Check: Icon,
}
@@ -460,10 +461,11 @@ describe('UsageRecordsTable', () => {
.toBe('会话压缩')
})
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
it('shows mapping and response models as separately labelled facts', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
@@ -476,6 +478,8 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('gpt-5.1')
expect(root.textContent).toContain('映射模型')
expect(root.textContent).toContain('响应模型')
expect(root.textContent).toContain('xhigh -> max')
expect(root.textContent).toContain('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
@@ -509,15 +513,35 @@ describe('UsageRecordsTable', () => {
expect(inlineLayout).not.toBeNull()
const modelRow = inlineLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('order-last')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent).toContain('映射模型')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast')
expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber')
})
it('shows a response model without inventing a mapping arrow or using model_version', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5.1',
model_version: 'legacy-version',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(root.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(root.textContent).not.toContain('legacy-version')
expect(root.textContent).not.toContain('->')
})
it('hides a response model when it matches the request model', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-response]')).toBeNull()
})
it('stacks three model badges even without a model mapping', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
@@ -559,6 +583,37 @@ describe('UsageRecordsTable', () => {
},
)
it.each(['ultrafast', 'flex', 'future-tier', ' UltraFast '])(
'shows the final provider request tier %s without a fixed badge allowlist',
(requested) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: 'default',
})])
const badges = [...root.querySelectorAll<HTMLElement>('[data-usage-model-badge="service-tier"]')]
expect(badges.length).toBeGreaterThan(0)
for (const badge of badges) {
expect(badge.textContent?.trim()).toBe(requested.trim())
expect(badge.title).toBe(`上游请求档位:${requested.trim()}\n计费档位:${requested.trim()}`)
expect(badge.getAttribute('aria-label')).toBe(
`上游请求档位:${requested.trim()},计费档位:${requested.trim()}`,
)
}
},
)
it.each(['auto', 'default', 'standard', ' DEFAULT ', '', ' ', null])(
'does not show a badge for the default or absent request tier %s',
(requested) => {
const root = mountUsageRecordsTable([buildRecord({
service_tier: requested,
actual_service_tier: 'ultrafast',
})])
expect(root.querySelector('[data-usage-model-badge="service-tier"]')).toBeNull()
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
},
)
it.each(['default', 'flex', null])(
'ignores the response-side tier %s when the request tier is Fast',
(actualServiceTier) => {
@@ -679,4 +734,37 @@ describe('UsageRecordsTable', () => {
expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull()
expect(root.querySelector('[data-usage-attempt-marker="retry"]')).not.toBeNull()
})
it('shows the skipped-candidate marker when a candidate was skipped by scheduling', () => {
const root = mountUsageRecordsTable([buildRecord({
has_skipped_candidate: true,
skipped_candidate_reasons: ['key_rpm_exhausted'],
})])
const marker = root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')
expect(marker).not.toBeNull()
// tooltip 必须说明"请求未发往该候选",否则用户会以为上游报了错
const title = marker?.getAttribute('title') ?? ''
expect(title).toContain('调度阶段被跳过')
expect(title).toContain('不会有上游报错')
expect(title).toContain('密钥本分钟请求数已达上限')
})
it('prefers the fallback marker over the skipped-candidate marker', () => {
// 真正发生过故障转移时,琥珀色转移图标信息量更大,不再叠加灰色角标
const root = mountUsageRecordsTable([buildRecord({
has_fallback: true,
has_skipped_candidate: true,
skipped_candidate_reasons: ['key_rpm_exhausted'],
})])
expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull()
expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull()
})
it('hides the skipped-candidate marker when no candidate was skipped', () => {
const root = mountUsageRecordsTable([buildRecord({ has_skipped_candidate: false })])
expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull()
})
})
@@ -731,7 +731,13 @@ export function useUsageData(options: UseUsageDataOptions) {
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier
: null)
: existing.actual_service_tier
: existing.actual_service_tier,
// 终态列表快照是最终候选的权威事实;空值必须清除旧候选的响应模型。
response_model: statusProgressed
? (typeof record.response_model === 'string' && record.response_model.trim()
? record.response_model
: null)
: existing.response_model
}
})
}
@@ -3,6 +3,7 @@ import type { UsageRecord, FilterStatusValue } from '../types'
import {
hasUsageFallback,
hasUsageRetry,
hasUsageSkippedCandidate,
isUsageRecordFailed,
isUsageUpstreamStream,
isUsageWebSocket,
@@ -97,6 +98,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => hasUsageRetry(record))
} else if (filterStatus.value === 'has_skipped_candidate') {
records = records.filter(record => hasUsageSkippedCandidate(record))
}
}
+2 -1
View File
@@ -102,7 +102,8 @@ export type FilterStatusValue =
'failed' |
'cancelled' |
'has_fallback' |
'has_retry'
'has_retry' |
'has_skipped_candidate'
// 默认统计状态
export function createDefaultStats(): UsageStatsState {
@@ -1,12 +1,31 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { gzipSync } from 'node:zlib'
import { BodyDocumentEngine, decodeBody } from '../body-document-engine'
import { JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from '../json-viewer'
import type { BodyWorkerRequest } from '../body-document-protocol'
function bytes(value: string) { return new TextEncoder().encode(value).buffer }
function gzip(value: string) { return Uint8Array.from(gzipSync(value)).buffer }
describe('body document decoding', () => {
it('loads and copies complete captured bodies through the worker entry point', async () => {
const postMessage = vi.fn()
vi.stubGlobal('postMessage', postMessage)
vi.stubGlobal('onmessage', undefined)
try {
await import('../body-document.worker')
const dispatch = globalThis.onmessage as unknown as (event: { data: BodyWorkerRequest }) => Promise<void>
const value = { messages: [{ role: 'user', content: `${'x'.repeat(100_000)}BODY-END` }] }
const text = JSON.stringify(value)
await dispatch({ data: { id: 1, action: 'load', bytes: gzip(text), encoding: 'gzip' } })
expect(postMessage).toHaveBeenLastCalledWith({ id: 1, ok: true, result: { byteLength: bytes(text).byteLength } })
await dispatch({ data: { id: 2, action: 'copy' } })
expect(postMessage).toHaveBeenLastCalledWith({ id: 2, ok: true, result: JSON.stringify(value, null, 2) })
} finally {
vi.unstubAllGlobals()
}
})
it.each(['gzip', 'json'] as const)('decodes %s off the UI protocol with a byte count', async encoding => {
const text = JSON.stringify({ text: '你好🙂', count: 0, enabled: false })
const decoded = await decodeBody(encoding === 'gzip' ? gzip(text) : bytes(text), encoding)
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
CANDIDATE_SKIP_REASON_LABELS,
formatCandidateSkipReason,
isNonAttemptedCandidateStatus,
isSkippedCandidateStatus,
} from '../skipReason'
describe('candidate skip reason formatting', () => {
it('translates known skip reasons into Chinese labels', () => {
expect(formatCandidateSkipReason('key_rpm_exhausted')).toBe('密钥本分钟请求数已达上限')
expect(formatCandidateSkipReason('provider_concurrency_limit_reached')).toBe('上游提供商并发已达上限')
expect(formatCandidateSkipReason('key_circuit_open')).toBe('密钥熔断中(连续失败后暂停)')
})
it('trims surrounding whitespace before lookup', () => {
expect(formatCandidateSkipReason(' key_rpm_exhausted ')).toBe('密钥本分钟请求数已达上限')
})
it('falls back to the raw reason so new backend reasons stay visible', () => {
// 后端新增白名单原因、前端还没补翻译时,不能丢信息。
expect(formatCandidateSkipReason('brand_new_reason')).toBe('brand_new_reason')
})
it('returns an empty string for missing or blank reasons', () => {
expect(formatCandidateSkipReason(undefined)).toBe('')
expect(formatCandidateSkipReason(null)).toBe('')
expect(formatCandidateSkipReason(' ')).toBe('')
})
it('labels every backend allowlisted reason', () => {
// 与后端 REQUEST_CANDIDATE_SKIP_REASONS 白名单保持同步的关键集合抽查。
const criticalReasons = [
'account_quota_exhausted',
'api_key_concurrency_limit_reached',
'auth_api_key_concurrency_limit_reached',
'key_circuit_open',
'key_health_score_zero',
'key_rpm_exhausted',
'pool_key_lease_busy',
'provider_concurrency_limit_reached',
'provider_key_concurrency_limit_reached',
'provider_quota_blocked',
'transport_unsupported',
]
for (const reason of criticalReasons) {
expect(CANDIDATE_SKIP_REASON_LABELS[reason], `missing label for ${reason}`).toBeTruthy()
}
})
it('distinguishes skipped candidates from attempted ones', () => {
expect(isSkippedCandidateStatus('skipped')).toBe(true)
expect(isSkippedCandidateStatus('failed')).toBe(false)
expect(isSkippedCandidateStatus(undefined)).toBe(false)
// available/unused 同样是"从未尝试",用于时间线是否展示的判定。
expect(isNonAttemptedCandidateStatus('skipped')).toBe(true)
expect(isNonAttemptedCandidateStatus('available')).toBe(true)
expect(isNonAttemptedCandidateStatus('unused')).toBe(true)
expect(isNonAttemptedCandidateStatus('success')).toBe(false)
expect(isNonAttemptedCandidateStatus('failed')).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
formatUsageStreamLabel,
hasUsageFallback,
hasUsageRetry,
hasUsageSkippedCandidate,
isUsageRecordFailed,
isUsageRecordSuccessful,
isUsageWebSocket,
@@ -178,6 +179,19 @@ describe('usage status helpers', () => {
expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false)
})
it('uses explicit has_skipped_candidate flag for scheduling-skip filtering', () => {
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: true }))).toBe(true)
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: false }))).toBe(false)
expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: undefined }))).toBe(false)
})
it('keeps skipped-candidate signal independent from fallback signal', () => {
// 被调度跳过 ≠ 故障转移:前者请求从未发出,因此不应被 hasUsageFallback 认领
const skippedOnly = buildUsageRecord({ has_skipped_candidate: true, has_fallback: false })
expect(hasUsageSkippedCandidate(skippedOnly)).toBe(true)
expect(hasUsageFallback(skippedOnly)).toBe(false)
})
it('recognizes persisted WebSocket usage records', () => {
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true)
expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false)
@@ -1,7 +1,11 @@
import type { FilterStatusValue } from '../types'
export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean {
return status === 'has_retry' || status === 'has_fallback'
// 这三个标记都由后端在列表响应里直接给出,但用户侧接口不支持作为服务端筛选条件,
// 因此统一走前端本地过滤。
return status === 'has_retry'
|| status === 'has_fallback'
|| status === 'has_skipped_candidate'
}
export function shouldUseServerUserRecordFilters(input: {
@@ -0,0 +1,98 @@
/**
* 候选"被跳过"原因的中文标签。
*
* 这些字符串来自后端 `StoredRequestCandidate.skip_reason`,取值受
* `crates/aether-data/contracts/src/repository/candidates/types.rs` 里的
* `REQUEST_CANDIDATE_SKIP_REASONS` 白名单约束(未知原因会被后端统一清洗成
* `unclassified_candidate_skip_reason`)。
*
* 维护约定:后端新增白名单项时,这里补一条中文说明;缺失时前端会原样显示英文,
* 不会丢信息,所以可以安全地"先上线原因、后补翻译"。
*/
export const CANDIDATE_SKIP_REASON_LABELS: Record<string, string> = {
// —— 调度期运行时可选择性(不满足条件,从未真正发起请求)——
account_quota_exhausted: '账号额度已耗尽',
oauth_invalid: 'OAuth 凭证已失效',
provider_quota_blocked: '上游提供商额度已用尽',
provider_concurrency_limit_reached: '上游提供商并发已达上限',
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
provider_inactive: '提供商已停用',
key_inactive: '密钥已停用',
key_circuit_open: '密钥熔断中(连续失败后暂停)',
key_health_score_zero: '密钥健康分为 0',
key_rpm_exhausted: '密钥本分钟请求数已达上限',
key_model_disabled: '该密钥已停用此模型',
key_model_not_allowed: '该密钥不允许使用此模型',
key_api_format_disabled: '该密钥已停用此 API 格式',
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
// —— 传输与路由策略门(在真正发请求前就被拦下)——
auth_channel_mismatch: '鉴权通道不匹配',
auth_snapshot_missing: '缺少该密钥的鉴权快照',
endpoint_api_format_changed: '端点 API 格式已变更',
endpoint_inactive: '端点已停用',
format_conversion_disabled: '该提供商未开启格式转换',
mapped_model_missing: '缺少映射后的上游模型',
routing_profile_disallowed_key: '路由策略未允许该密钥',
routing_profile_disallowed_provider: '路由策略未允许该提供商',
upstream_url_missing: '缺少上游地址',
gemini_file_mapping_mismatch: 'Gemini 文件映射不匹配',
provider_request_body_build_failed: '上游请求体转换失败',
provider_request_body_missing: '无法构建上游请求体',
// —— transport_* 系列:该提供商/端点不支持当前这种转发方式 ——
transport_unsupported: '该传输方式不受支持',
transport_api_format_mismatch: 'API 格式与端点不匹配',
transport_api_format_unsupported: '不支持该 API 格式',
transport_auth_unavailable: '无法获取可用鉴权',
transport_body_rules_apply_failed: '请求体改写规则执行失败',
transport_body_rules_unsupported: '不支持请求体改写规则',
transport_body_rules_unsupported_for_binary_upload: '二进制上传不支持请求体改写规则',
transport_custom_path_unsupported: '不支持自定义路径',
transport_endpoint_kind_unsupported: '不支持该端点类型',
transport_header_rules_apply_failed: '请求头改写规则执行失败',
transport_header_rules_unsupported: '不支持请求头改写规则',
transport_oauth_resolution_unsupported: '不支持该 OAuth 解析方式',
transport_operation_unsupported: '不支持该操作类型',
transport_profile_unsupported: '不支持该传输配置',
transport_provider_type_unsupported: '不支持该提供商类型',
transport_proxy_or_profile_unsupported: '不支持代理或传输配置',
transport_proxy_unsupported: '不支持该代理',
transport_snapshot_missing: '缺少传输快照',
// —— 号池(pool)相关 ——
pool_group_exhausted: '号池已无可用账号',
pool_account_blocked: '号池账号已被封禁',
pool_account_exhausted: '号池账号额度已耗尽',
pool_active_probe_sealed: '号池探测中,暂不分配',
pool_cooldown: '号池账号冷却中',
pool_cost_limit_reached: '号池费用已达上限',
pool_key_lease_busy: '池内账号正被其他请求占用',
pool_score_member_missing: '号池评分成员缺失',
}
/** 后端无法归类时使用的占位原因。 */
export const UNCLASSIFIED_CANDIDATE_SKIP_REASON = 'unclassified_candidate_skip_reason'
/**
* 把候选跳过原因转成中文展示文案。
*
* 命中已知标签时返回中文;否则原样返回后端字符串(便于排查新原因),
* 空值返回空字符串,调用方据此判断是否展示。
*/
export function formatCandidateSkipReason(reason?: string | null): string {
const normalized = typeof reason === 'string' ? reason.trim() : ''
if (!normalized) return ''
return CANDIDATE_SKIP_REASON_LABELS[normalized] ?? normalized
}
/** 是否为"根本没有向上游发起请求"的跳过状态。 */
export function isSkippedCandidateStatus(status?: string | null): boolean {
return status === 'skipped'
}
/** 是否为"被枚举出来但从未尝试"的候选状态(含跳过与未使用)。 */
export function isNonAttemptedCandidateStatus(status?: string | null): boolean {
return status === 'skipped' || status === 'available' || status === 'unused'
}
@@ -49,6 +49,16 @@ export function hasUsageRetry(
return record.has_retry === true
}
/**
* 是否有候选在调度阶段被跳过(请求从未发往该候选)。
* 与 hasUsageFallback 互补:后者要求"更靠前的候选确实尝试并失败"。
*/
export function hasUsageSkippedCandidate(
record: Pick<UsageRecord, 'has_skipped_candidate'>
): boolean {
return record.has_skipped_candidate === true
}
export function isUsageWebSocket(
record: Pick<UsageRecord, 'is_websocket'>
): boolean {
@@ -2,7 +2,7 @@
<Dialog
:model-value="open"
:title="legacyT('用户批量操作')"
:description="legacyT('按当前选择批量调整用户状态、角色和额度')"
:description="legacyT('按当前选择批量调整用户状态、角色、额度和钱包余额')"
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
@@ -39,6 +39,98 @@
v-model="quotaMode"
/>
<div
v-if="selectedAction === 'adjust_wallet_balance'"
class="space-y-3 rounded-xl border border-border bg-background p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Label
for="user-batch-wallet-amount"
class="text-sm font-medium"
>
{{ legacyT('调整金额 (USD)') }}
</Label>
<div
class="inline-flex rounded-md border border-border p-0.5"
role="group"
:aria-label="legacyT('余额调整方式')"
>
<Button
type="button"
size="sm"
:variant="balanceOperation === 'add' ? 'default' : 'ghost'"
:aria-pressed="balanceOperation === 'add'"
@click="balanceOperation = 'add'"
>
<Plus class="mr-1.5 h-4 w-4" />
{{ legacyT('增加') }}
</Button>
<Button
type="button"
size="sm"
:variant="balanceOperation === 'deduct' ? 'default' : 'ghost'"
:aria-pressed="balanceOperation === 'deduct'"
@click="balanceOperation = 'deduct'"
>
<Minus class="mr-1.5 h-4 w-4" />
{{ legacyT('扣减') }}
</Button>
</div>
</div>
<Input
id="user-batch-wallet-amount"
:model-value="balanceAmount"
type="number"
min="0"
step="any"
inputmode="decimal"
:aria-invalid="balanceAmount !== '' && balancePayload === null"
@update:model-value="balanceAmount = String($event)"
/>
<p
v-if="balanceAmount !== '' && balancePayload === null"
class="text-xs text-destructive"
>
{{ legacyT('请输入大于 0 的有限金额') }}
</p>
<p class="text-xs leading-relaxed text-muted-foreground">
{{ legacyT('扣减超过单个用户可用余额时,该用户余额将归零。') }}
</p>
</div>
<div
v-if="pendingWalletBatch"
role="alert"
class="space-y-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
>
<p>
{{ legacyT('存在未决的钱包批量调整') }}:{{ pendingWalletOperationLabel }} {{ pendingWalletBatch.request.payload.amount }} USD。{{ legacyT('结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。') }}
</p>
<p
v-if="walletRequestMismatch"
class="text-xs"
>
{{ legacyT('当前表单与原请求不同;新钱包调整已禁用,请先重试原请求。') }}
</p>
<Button
type="button"
size="sm"
variant="outline"
:disabled="executing"
@click="retryPendingWalletBatch"
>
<RotateCcw class="mr-1.5 h-4 w-4" />
{{ legacyT('重试原钱包批次') }}
</Button>
</div>
<p
v-else-if="pendingWalletReadError"
role="alert"
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
>
{{ legacyT('无法读取未决的钱包批量请求。为避免重复扣款,钱包余额调整已禁用;请先核对余额操作结果。') }}
</p>
<UserBatchResultSummary
:result="lastResult"
:label="lastResultLabel"
@@ -69,8 +161,12 @@ import { computed, ref, watch } from 'vue'
import {
Dialog,
Button,
Input,
Label,
} from '@/components/ui'
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n'
@@ -82,11 +178,27 @@ import UserBatchRolePanel from './UserBatchRolePanel.vue'
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
import type { UserBatchQuotaMode } from './user-management-types'
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
matchesPendingWalletRequest,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyRequestInProgressError,
WalletIdempotencyScopeChangedError,
WalletIdempotencyScopeUnavailableError,
WalletIdempotencyUnavailableError,
} from '../utils/userBatchWalletIdempotency'
import type {
PendingUserBatchWalletRequest,
UserBatchWalletAdjustmentRequest,
} from '../utils/userBatchWalletIdempotency'
import type {
UserBatchAccessControlPayload,
UserBatchAction,
UserBatchActionRequest,
UserBatchActionResponse,
UserBatchBalanceOperation,
UserBatchRolePayload,
UserBatchSelection,
UserBatchSelectionFilters,
@@ -110,22 +222,51 @@ const emit = defineEmits<{
}>()
const usersStore = useUsersStore()
const authStore = useAuthStore()
const walletRetryCoordinator = createUserBatchWalletRetryCoordinator({
scope: () => authStore.user?.id ?? null,
})
const { success, warning, error } = useToast()
const { legacyT, locale } = useI18n()
const selectedAction = ref<UserBatchAction>('enable')
const targetRole = ref<UserRole>('user')
const quotaMode = ref<UserBatchQuotaMode>('skip')
const balanceOperation = ref<UserBatchBalanceOperation>('add')
const balanceAmount = ref('')
const selectedGroupIds = ref<string[]>([])
const previewLoading = ref(false)
const previewItems = ref<UserBatchSelectionItem[]>([])
const resolvedTotal = ref<number | null>(null)
const executing = ref(false)
const lastResult = ref<UserBatchActionResponse | null>(null)
const pendingWalletBatch = ref<PendingUserBatchWalletRequest | null>(null)
const pendingWalletReadError = ref(false)
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
balanceOperation.value,
balanceAmount.value,
))
const walletAdjustmentRequest = computed<UserBatchWalletAdjustmentRequest | null>(() => (
balancePayload.value === null
? null
: { selection: buildSelection(), action: 'adjust_wallet_balance', payload: balancePayload.value }
))
const walletRequestMismatch = computed(() => (
pendingWalletBatch.value !== null
&& selectedAction.value === 'adjust_wallet_balance'
&& (walletAdjustmentRequest.value === null
|| !matchesPendingWalletRequest(pendingWalletBatch.value, walletAdjustmentRequest.value))
))
const canExecute = computed(() => (
hasAnyTarget.value
&& !previewLoading.value
&& !executing.value
&& (selectedAction.value !== 'adjust_wallet_balance'
|| (balancePayload.value !== null && !pendingWalletReadError.value && !walletRequestMismatch.value))
))
const selectedActionLabel = computed(() => (
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
))
@@ -143,13 +284,25 @@ const targetRoleWarning = computed(() => {
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
})
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
const pendingWalletOperationLabel = computed(() => (
pendingWalletBatch.value?.request.payload.operation === 'deduct'
? legacyT('扣减')
: legacyT('增加')
))
const lastResultLabel = computed(() => {
if (!lastResult.value) return ''
if (lastResult.value.interrupted) {
return legacyT(
`批量操作中断:成功 ${lastResult.value.success} 个,结果待确认 ${lastResult.value.uncertain_user_ids?.length ?? 0} 个,尚未执行 ${lastResult.value.unprocessed_user_ids?.length ?? 0} 个`,
)
}
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
})
const lastResultFailuresLabel = computed(() => {
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
const failures = lastResult.value.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(locale.value === 'en-US' ? '; ' : ';')
const failures = lastResult.value.failures.slice(0, 3)
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
.join(locale.value === 'en-US' ? '; ' : ';')
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
})
@@ -158,8 +311,15 @@ watch(
(open) => {
if (!open) return
resetLocalState()
refreshPendingWalletBatch()
void resolvePreview()
},
{ immediate: true },
)
watch(
() => authStore.user?.id,
() => refreshPendingWalletBatch(),
)
watch(
@@ -177,10 +337,22 @@ function resetLocalState(): void {
selectedAction.value = 'enable'
targetRole.value = 'user'
quotaMode.value = 'skip'
balanceOperation.value = 'add'
balanceAmount.value = ''
selectedGroupIds.value = []
lastResult.value = null
}
function refreshPendingWalletBatch(): void {
try {
pendingWalletBatch.value = walletRetryCoordinator.getPending()
pendingWalletReadError.value = false
} catch {
pendingWalletBatch.value = null
pendingWalletReadError.value = true
}
}
function buildSelection(): UserBatchSelection {
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
if (props.selectAllFiltered) {
@@ -227,7 +399,8 @@ function buildRolePayload(): UserBatchRolePayload {
async function executeBatchAction(): Promise<void> {
if (!canExecute.value) return
const selection = buildSelection()
let request: UserBatchActionRequest
let request: Exclude<UserBatchActionRequest, { action: 'adjust_wallet_balance' }> | null = null
let walletRequest: UserBatchWalletAdjustmentRequest | null = null
if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload()
if (payload === null) {
@@ -235,6 +408,12 @@ async function executeBatchAction(): Promise<void> {
return
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'adjust_wallet_balance') {
if (walletAdjustmentRequest.value === null) {
warning(legacyT('请输入大于 0 的有限金额'))
return
}
walletRequest = walletAdjustmentRequest.value
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
@@ -243,8 +422,22 @@ async function executeBatchAction(): Promise<void> {
executing.value = true
try {
if (walletRequest) {
const result = await walletRetryCoordinator.execute(
walletRequest,
(keyedRequest) => usersStore.batchAction(keyedRequest),
)
handleWalletBatchResult(result)
return
}
if (request === null) return
const result = await usersStore.batchAction(request)
lastResult.value = result
if (result.interrupted) {
warning(`${lastResultLabel.value};${legacyT('请核对余额后再重试,勿直接重试整批')}`)
emit('completed', result)
return
}
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
if (result.failed > 0) {
warning(message)
@@ -253,9 +446,71 @@ async function executeBatchAction(): Promise<void> {
}
emit('completed', result)
} catch (err) {
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
if (walletRequest) {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if (
err instanceof WalletIdempotencyUnavailableError
|| err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError
) {
warning(legacyT('无法确认管理员身份或安全生成钱包批量请求标识,请求未发送。'))
} else {
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
}
} else {
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
}
} finally {
executing.value = false
}
}
async function retryPendingWalletBatch(): Promise<void> {
if (executing.value) return
executing.value = true
try {
const result = await walletRetryCoordinator.retry(
(request) => usersStore.batchAction(request),
)
if (result) handleWalletBatchResult(result)
else refreshPendingWalletBatch()
} catch (err) {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if (
err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError
) {
warning(legacyT('无法确认管理员身份,请求未发送。'))
} else {
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
}
} finally {
executing.value = false
}
}
function handleWalletBatchResult(result: UserBatchActionResponse): void {
lastResult.value = result
refreshPendingWalletBatch()
if (result.interrupted) {
warning(`${lastResultLabel.value};${legacyT('结果可能部分完成,请仅重试原请求,不要开始新的余额调整。')}`)
} else {
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
if (result.failed > 0) warning(message)
else success(message)
}
emit('completed', result)
}
</script>
@@ -7,15 +7,35 @@
<span v-if="failuresLabel">
{{ failuresLabel }}
</span>
<details
v-if="result.interrupted"
class="mt-2 border-t border-border/60 pt-2"
>
<summary class="cursor-pointer select-none">
{{ legacyT('查看批次中断详情') }}
</summary>
<div class="mt-2 max-h-32 space-y-1 overflow-auto break-all">
<p>{{ legacyT('已完成用户 ID') }}:{{ userIds(result.completed_user_ids) }}</p>
<p>{{ legacyT('结果待核对用户 ID') }}:{{ userIds(result.uncertain_user_ids) }}</p>
<p>{{ legacyT('尚未执行用户 ID') }}:{{ userIds(result.unprocessed_user_ids) }}</p>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import type { UserBatchActionResponse } from '@/api/users'
import { useI18n } from '@/i18n'
defineProps<{
result: UserBatchActionResponse | null
label: string
failuresLabel: string
}>()
const { legacyT } = useI18n()
function userIds(ids?: string[]): string {
return ids && ids.length > 0 ? ids.join(', ') : '-'
}
</script>
@@ -3,6 +3,7 @@ import {
CheckCircle2,
ShieldCheck,
UserCog,
Wallet,
} from 'lucide-vue-next'
import type { Component } from 'vue'
import type { UserBatchAction, UserRole } from '@/api/users'
@@ -64,6 +65,12 @@ export const USER_BATCH_ACTION_OPTIONS: UserBatchActionOption[] = [
description: '批量设为普通用户或管理员',
icon: UserCog,
},
{
value: 'adjust_wallet_balance',
label: '调整余额',
description: '批量增加或扣减钱包余额',
icon: Wallet,
},
]
export function formatUserRoleLabel(role: UserRole | string): string {
@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyRequestInProgressError,
UnresolvedWalletRequestMismatchError,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyUnavailableError,
} from '../userBatchWalletIdempotency'
function createStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
const walletRequest = {
selection: { user_ids: ['user-1', 'user-2'], group_ids: ['group-1'] },
action: 'adjust_wallet_balance' as const,
payload: { operation: 'deduct' as const, amount: 17.25 },
}
const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
let testFallback: Map<string, string>
let testLockManager: Pick<LockManager, 'request'>
type CoordinatorOptions = NonNullable<Parameters<typeof createUserBatchWalletRetryCoordinator>[0]>
function createTestLockManager(): Pick<LockManager, 'request'> {
const heldNames = new Set<string>()
const request = async <T>(
name: string,
_options: LockOptions,
callback: (lock: Lock | null) => Promise<T>,
): Promise<T> => {
if (heldNames.has(name)) return callback(null)
heldNames.add(name)
try {
return await callback({ name, mode: 'exclusive' } as Lock)
} finally {
heldNames.delete(name)
}
}
return { request } as unknown as Pick<LockManager, 'request'>
}
function createCoordinator(options: Omit<CoordinatorOptions, 'lockManager'> = {}) {
return createUserBatchWalletRetryCoordinator({ ...options, lockManager: testLockManager })
}
function response(interrupted = false): UserBatchActionResponse {
return { total: 2, success: 1, failed: 0, failures: [], interrupted }
}
describe('user batch wallet idempotency', () => {
beforeEach(() => {
sessionStorage.clear()
localStorage.clear()
testFallback = new Map()
testLockManager = createTestLockManager()
})
it('serializes the key with the exact top-level wallet request before sending', async () => {
const storage = createStorage()
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-1',
})
let storedDuringSend: string | null = null
let sentRequest: UserBatchBalanceActionRequest | undefined
await coordinator.execute(walletRequest, async (request) => {
sentRequest = request
storedDuringSend = storage.getItem(defaultStorageKey)
return response(true)
})
expect(sentRequest).toEqual({ ...walletRequest, idempotency_key: 'wallet-key-1' })
expect(JSON.parse(storedDuringSend ?? 'null')).toEqual({
idempotency_key: 'wallet-key-1',
request: sentRequest,
})
})
it('rejects a second tab while the same wallet batch request is in progress', async () => {
const storage = createStorage()
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-first-tab',
})
const second = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-second-tab',
})
let resolveFirst!: (value: UserBatchActionResponse) => void
const firstSend = vi.fn(() => new Promise<UserBatchActionResponse>((resolve) => {
resolveFirst = resolve
}))
const secondSend = vi.fn(async () => response())
const firstExecution = first.execute(walletRequest, firstSend)
await vi.waitFor(() => expect(firstSend).toHaveBeenCalledOnce())
await expect(second.execute(walletRequest, secondSend)).rejects.toBeInstanceOf(
WalletIdempotencyRequestInProgressError,
)
expect(secondSend).not.toHaveBeenCalled()
resolveFirst(response())
await expect(firstExecution).resolves.toEqual(response())
expect(firstSend).toHaveBeenCalledOnce()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('fails closed when cross-tab request coordination is unavailable', async () => {
const storage = createStorage()
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
lockManager: null,
createKey: () => 'wallet-key-without-locks',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyCoordinationUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('retains a transport failure and reopens with the exact request for retry', async () => {
const storage = createStorage()
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-2',
})
const sendFailure = new Error('connection lost')
await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
const reopened = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
})
const pending = reopened.getPending()
expect(pending).toEqual({
idempotency_key: 'wallet-key-2',
request: { ...walletRequest, idempotency_key: 'wallet-key-2' },
})
const send = vi.fn(async () => response())
await expect(reopened.retry(send)).resolves.toEqual(response())
expect(send).toHaveBeenCalledWith(pending?.request)
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
const first = createCoordinator({
createKey: () => 'wallet-key-persistent',
scope: () => 'admin-1',
})
await expect(first.execute(walletRequest, async () => {
throw new Error('connection lost')
})).rejects.toThrow('connection lost')
const reopened = createCoordinator({ scope: () => 'admin-1' })
const pending = reopened.getPending()
expect(pending?.request).toEqual({
...walletRequest,
idempotency_key: 'wallet-key-persistent',
})
const send = vi.fn(async () => response())
await reopened.retry(send)
expect(send).toHaveBeenCalledWith(pending?.request)
expect(localStorage.getItem(
'admin.users.batch.wallet-adjustment.pending.v1:admin-1',
)).toBeNull()
})
it('keeps unresolved requests isolated by authenticated administrator', async () => {
const storage = createStorage()
const adminA = createCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-a',
createKey: () => 'wallet-key-admin-a',
})
await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost')
const adminB = createCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-b',
createKey: () => 'wallet-key-admin-b',
})
expect(adminB.getPending()).toBeNull()
await adminB.execute(walletRequest, async () => response(true))
expect(adminA.getPending()?.idempotency_key).toBe('wallet-key-admin-a')
expect(adminB.getPending()?.idempotency_key).toBe('wallet-key-admin-b')
})
it('does not send if the authenticated administrator changes before dispatch', async () => {
const storage = createStorage()
let scopeReads = 0
const send = vi.fn(async () => response())
const coordinator = createCoordinator({
storage,
fallback: testFallback,
scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'),
createKey: () => 'wallet-key-scope-change',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toThrow(
'authenticated administrator changed',
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem('admin.users.batch.wallet-adjustment.pending.v1:admin-a')).not.toBeNull()
})
it('retains interrupted requests and reuses their key until a terminal response', async () => {
const storage = createStorage()
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-3',
})
await first.execute(walletRequest, async () => response(true))
const reopened = createCoordinator({ storage, fallback: testFallback })
const pending = reopened.getPending()
const send = vi.fn(async () => response(true))
await reopened.retry(send)
expect(send).toHaveBeenCalledWith(pending?.request)
expect(reopened.getPending()).toEqual(pending)
await reopened.retry(async (request) => {
expect(request).toEqual(pending?.request)
return response()
})
expect(reopened.getPending()).toBeNull()
})
it('matches the same serialized request when optional filter fields are omitted', async () => {
const storage = createStorage()
const requestWithUndefinedField = {
...walletRequest,
selection: { filters: { search: 'active', is_active: undefined } },
}
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-filter',
})
await first.execute(requestWithUndefinedField, async () => response(true))
const reopened = createCoordinator({ storage, fallback: testFallback })
const send = vi.fn(async () => response())
await reopened.execute({
...walletRequest,
selection: { filters: { search: 'active' } },
}, send)
expect(send).toHaveBeenCalledWith({
...walletRequest,
selection: { filters: { search: 'active' } },
idempotency_key: 'wallet-key-filter',
})
})
it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
const storage = createStorage()
let nextKey = 0
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => `wallet-key-${++nextKey}`,
})
await expect(coordinator.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost')
const changedRequest = {
...walletRequest,
payload: { operation: 'add' as const, amount: 20 },
}
const send = vi.fn(async () => response())
await expect(coordinator.execute(changedRequest, send)).rejects.toBeInstanceOf(
UnresolvedWalletRequestMismatchError,
)
expect(send).not.toHaveBeenCalled()
expect(coordinator.getPending()?.idempotency_key).toBe('wallet-key-1')
await coordinator.retry(async () => response())
let newRequest
await coordinator.execute(changedRequest, async (request) => {
newRequest = request
return response()
})
expect(newRequest).toEqual({ ...changedRequest, idempotency_key: 'wallet-key-2' })
})
it('fails closed when persistent storage cannot save the request', async () => {
const unavailableStorage = {
getItem: () => null,
setItem: () => { throw new Error('storage unavailable') },
removeItem: () => undefined,
}
const send = vi.fn(async () => response(true))
const coordinator = createCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'wallet-key-fallback',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('does not send when persistent storage readback does not match', async () => {
let wasWritten = false
const mismatchedStorage = {
getItem: () => wasWritten ? 'different request' : null,
setItem: () => { wasWritten = true },
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createCoordinator({
storage: mismatchedStorage,
fallback: testFallback,
createKey: () => 'wallet-key-readback',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('does not create a new request when persistent storage cannot be read', async () => {
const unavailableStorage = {
getItem: () => { throw new Error('storage unavailable') },
setItem: () => undefined,
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('fails closed when secure UUID generation is unavailable', async () => {
const storage = createStorage()
const send = vi.fn(async () => response())
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => { throw new WalletIdempotencyUnavailableError() },
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
})
@@ -0,0 +1,302 @@
import type {
UserBatchActionResponse,
UserBatchBalanceActionRequest,
} from '@/api/users'
export type UserBatchWalletAdjustmentRequest = Omit<
UserBatchBalanceActionRequest,
'idempotency_key'
>
export interface PendingUserBatchWalletRequest {
idempotency_key: string
request: UserBatchBalanceActionRequest
}
interface StringStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
interface CoordinatorOptions {
storage?: StringStorage | null
createKey?: () => string
fallback?: Map<string, string>
lockManager?: Pick<LockManager, 'request'> | null
scope?: () => string | null
}
const STORAGE_KEY = 'admin.users.batch.wallet-adjustment.pending.v1'
const inMemoryFallback = new Map<string, string>()
export class UnresolvedWalletRequestMismatchError extends Error {
constructor(readonly pending: PendingUserBatchWalletRequest) {
super('A different wallet batch request is still unresolved')
this.name = 'UnresolvedWalletRequestMismatchError'
}
}
export class WalletIdempotencyUnavailableError extends Error {
constructor() {
super('crypto.randomUUID is unavailable')
this.name = 'WalletIdempotencyUnavailableError'
}
}
export class WalletIdempotencyPersistenceUnavailableError extends Error {
constructor() {
super('Persistent browser storage is unavailable')
this.name = 'WalletIdempotencyPersistenceUnavailableError'
}
}
export class WalletIdempotencyCoordinationUnavailableError extends Error {
constructor() {
super('Cross-tab wallet request coordination is unavailable')
this.name = 'WalletIdempotencyCoordinationUnavailableError'
}
}
export class WalletIdempotencyRequestInProgressError extends Error {
constructor() {
super('A wallet batch request is already in progress in another tab')
this.name = 'WalletIdempotencyRequestInProgressError'
}
}
export class WalletIdempotencyScopeUnavailableError extends Error {
constructor() {
super('The authenticated administrator identity is unavailable')
this.name = 'WalletIdempotencyScopeUnavailableError'
}
}
export class WalletIdempotencyScopeChangedError extends Error {
constructor() {
super('The authenticated administrator changed before the request was sent')
this.name = 'WalletIdempotencyScopeChangedError'
}
}
export class InvalidPendingWalletRequestError extends Error {
constructor() {
super('The stored wallet batch request is invalid')
this.name = 'InvalidPendingWalletRequestError'
}
}
function browserPersistentStorage(): StringStorage | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
function browserLockManager(): Pick<LockManager, 'request'> | null {
try {
return globalThis.navigator?.locks ?? null
} catch {
return null
}
}
function secureRandomUUID(): string {
try {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
} catch {
// Treat unavailable secure randomness as a hard failure.
}
throw new WalletIdempotencyUnavailableError()
}
function isPendingRequest(value: unknown): value is PendingUserBatchWalletRequest {
if (typeof value !== 'object' || value === null) return false
const record = value as Partial<PendingUserBatchWalletRequest>
const request = record.request
return typeof record.idempotency_key === 'string'
&& record.idempotency_key.length > 0
&& typeof request === 'object'
&& request !== null
&& request.action === 'adjust_wallet_balance'
&& request.idempotency_key === record.idempotency_key
&& typeof request.selection === 'object'
&& request.selection !== null
&& typeof request.payload === 'object'
&& request.payload !== null
&& (request.payload.operation === 'add' || request.payload.operation === 'deduct')
&& Number.isFinite(request.payload.amount)
&& request.payload.amount > 0
}
function parsePendingRequest(serialized: string): PendingUserBatchWalletRequest {
try {
const value: unknown = JSON.parse(serialized)
if (isPendingRequest(value)) return value
} catch {
// Invalid persisted state must not allow a fresh adjustment to be sent.
}
throw new InvalidPendingWalletRequestError()
}
function stableSerialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => item === undefined ? 'null' : stableSerialize(item)).join(',')}]`
}
if (typeof value === 'object' && value !== null) {
const fields = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
return `{${fields.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(',')}}`
}
return JSON.stringify(value) ?? 'null'
}
export function matchesPendingWalletRequest(
pending: PendingUserBatchWalletRequest,
request: UserBatchWalletAdjustmentRequest,
): boolean {
const { idempotency_key: _key, ...pendingPayload } = pending.request
return stableSerialize(pendingPayload) === stableSerialize(request)
}
export function createUserBatchWalletRetryCoordinator(options: CoordinatorOptions = {}) {
const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
const fallback = options.fallback ?? inMemoryFallback
const createKey = options.createKey ?? secureRandomUUID
const lockManager = 'lockManager' in options
? options.lockManager ?? null
: browserLockManager()
const getScope = options.scope ?? (() => 'default')
function getStorageKey(): string {
const scope = getScope()
if (!scope) throw new WalletIdempotencyScopeUnavailableError()
return `${STORAGE_KEY}:${encodeURIComponent(scope)}`
}
function readPending(storageKey: string): PendingUserBatchWalletRequest | null {
let serialized: string | null = null
let storageReadFailed = false
try {
serialized = storage?.getItem(storageKey) ?? null
} catch {
storageReadFailed = true
serialized = null
}
serialized ??= fallback.get(storageKey) ?? null
if (serialized === null && (!storage || storageReadFailed)) {
throw new WalletIdempotencyPersistenceUnavailableError()
}
return serialized === null ? null : parsePendingRequest(serialized)
}
function persist(storageKey: string, pending: PendingUserBatchWalletRequest): void {
const serialized = JSON.stringify(pending)
if (!storage) throw new WalletIdempotencyPersistenceUnavailableError()
try {
storage.setItem(storageKey, serialized)
if (storage.getItem(storageKey) !== serialized) {
throw new Error('Stored wallet batch request could not be verified')
}
} catch {
throw new WalletIdempotencyPersistenceUnavailableError()
}
fallback.set(storageKey, serialized)
}
function clear(storageKey: string): void {
fallback.delete(storageKey)
try {
storage?.removeItem(storageKey)
} catch {
// A stale persisted request is safe to replay and will fail closed on mismatch.
}
}
async function withExclusiveLock<T>(storageKey: string, task: () => Promise<T>): Promise<T> {
if (!lockManager) throw new WalletIdempotencyCoordinationUnavailableError()
let taskStarted = false
try {
return await lockManager.request(
storageKey,
{ mode: 'exclusive', ifAvailable: true },
async (lock) => {
if (lock === null) throw new WalletIdempotencyRequestInProgressError()
taskStarted = true
return task()
},
)
} catch (error) {
if (taskStarted || error instanceof WalletIdempotencyRequestInProgressError) throw error
throw new WalletIdempotencyCoordinationUnavailableError()
}
}
function getOrCreate(
storageKey: string,
request: UserBatchWalletAdjustmentRequest,
): UserBatchBalanceActionRequest {
const pending = readPending(storageKey)
if (pending) {
if (!matchesPendingWalletRequest(pending, request)) {
throw new UnresolvedWalletRequestMismatchError(pending)
}
persist(storageKey, pending)
return pending.request
}
const idempotencyKey = createKey()
if (!idempotencyKey) throw new WalletIdempotencyUnavailableError()
const keyedRequest: UserBatchBalanceActionRequest = {
...request,
idempotency_key: idempotencyKey,
}
persist(storageKey, { idempotency_key: idempotencyKey, request: keyedRequest })
return keyedRequest
}
async function sendAndResolve(
request: UserBatchBalanceActionRequest,
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
storageKey: string,
): Promise<UserBatchActionResponse> {
const response = await send(request)
if (!response.interrupted) clear(storageKey)
return response
}
return {
getPending() {
return readPending(getStorageKey())
},
async execute(
request: UserBatchWalletAdjustmentRequest,
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
) {
const storageKey = getStorageKey()
return withExclusiveLock(storageKey, async () => {
const keyedRequest = getOrCreate(storageKey, request)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(keyedRequest, send, storageKey)
})
},
async retry(
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
): Promise<UserBatchActionResponse | null> {
const storageKey = getStorageKey()
return withExclusiveLock(storageKey, async () => {
const pending = readPending(storageKey)
if (!pending) return null
persist(storageKey, pending)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(pending.request, send, storageKey)
})
},
}
}
@@ -50,6 +50,10 @@ describe('translation coverage', () => {
['1 维度', '1 dimension'],
['14天0时', '14d 0h'],
['5天 0:00:00', '5d 0:00:00'],
['确定删除选中的 3 个模型吗?\n\n此操作不可撤销。', 'Delete the selected 3 models?\n\nThis cannot be undone.'],
['确定删除选中的 1 个模型吗?\n\n此操作不可撤销。', 'Delete the selected 1 model?\n\nThis cannot be undone.'],
['成功删除 2 个模型', 'Deleted 2 models'],
['1 个模型删除失败', 'Failed to delete 1 model'],
])('translates %s as a complete message', (source, expected) => {
expect(translateLegacyText(source, 'en-US')).toBe(expected)
})
@@ -1,4 +1,13 @@
export const legacyAdminEnglishMessages: Record<string, string> = {
'缺少 OAuth 认证信息,请先授权/刷新 Token': 'Missing OAuth credentials; please authorize or refresh the token first',
'Key 状态写入失败': 'Failed to save key status',
'找不到有效的 claude:messages 端点': 'No active claude:messages endpoint found',
'响应中未包含配额信息': 'The response contains no quota information',
'响应中未包含额度窗口': 'The response contains no quota windows',
'oauth/usage 请求执行失败': 'oauth/usage request failed',
'oauth/usage 返回 401,Token 可能已失效,请刷新 Token': 'oauth/usage returned 401. The token may have expired; please refresh it',
'oauth/usage 返回 403,该账号缺少 user:profile 权限(如 Setup Token),无法查询额度': 'oauth/usage returned 403. This account lacks the user:profile scope (e.g. a Setup Token), so quota cannot be queried',
'oauth/usage 被限流,请稍后重试': 'oauth/usage was rate limited; please retry later',
'配置额度、流量限制、会员权益和组合套餐': 'Configure credit, usage limits, membership benefits, and bundled plans',
'新建套餐': 'Create plan',
'套餐列表': 'Plans',
@@ -613,6 +622,8 @@ export const legacyAdminEnglishMessages: Record<string, string> = {
'系统调账': 'System adjustment',
'退款扣减': 'Refund debit',
'退款回补': 'Refund recredit',
'另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。': 'Another tab is processing a wallet batch adjustment. Refresh the status and try again later. No new request was sent from this tab.',
'当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。': 'This browser cannot protect wallet batch requests across tabs. Use a browser that supports this feature. The request was not sent.',
'兑换码批次已创建': 'Redemption code batch created',
'CSV 已导出': 'CSV exported',
'批次已停用': 'Batch disabled',
+4 -1
View File
@@ -711,7 +711,7 @@ export const legacyUiEnglishMessages: Record<string, string> = {
'测试请求头必须是 JSON 对象': 'Test request headers must be a JSON object',
'号池调度模式': 'Pool scheduling mode',
'Responses WebSocket 模式': 'Responses WebSocket mode',
'允许此提供商处理标准 Responses API WebSocket 请求。仅在已验证兼容性后启用。': 'Allow this provider to handle standard Responses API WebSocket requests. Enable only after verifying compatibility.',
'允许此提供商处理标准 Responses API WebSocket 请求。': 'Allow this provider to handle standard Responses API WebSocket requests.',
'发放成功后,冲突的旧套餐及其组合权益会整包失效。确定继续发放吗?': 'Granting this plan will replace conflicting plans and all their bundled benefits. Continue?',
'确认替换旧套餐': 'Confirm plan replacement',
'继续发放': 'Grant plan',
@@ -1420,6 +1420,7 @@ export const legacyUiEnglishMessages: Record<string, string> = {
'批量导入': 'Bulk import',
'继续确认重置': 'Continue checking reset',
'临近过期': 'Expiring soon',
'Claude 重置机会': 'Claude quota reset',
'暂无密钥配置': 'No keys configured',
'暂无账号配置': 'No accounts configured',
'点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started',
@@ -1662,6 +1663,8 @@ export const legacyUiEnglishMessages: Record<string, string> = {
'依据窗口成本/Token 用量,缺失时回退配额使用率': 'Use window cost or token usage, falling back to quota utilization if unavailable',
'调度策略元数据加载失败,请重试': 'Failed to load scheduling policy metadata. Try again.',
'号池调度已保存': 'Pool scheduling saved',
'保留最低额度': 'Reserve minimum quota',
'账号剩余额度不高于 1% 时提前标记为额度耗尽并停止调度,待额度恢复后再使用。': 'Mark accounts as exhausted and stop scheduling when remaining quota is 1% or less, until quota recovers.',
'自适应热池': 'Adaptive warm pool',
'自动维护热池,缺口时异步补位。': 'Maintain a warm pool and replenish missing capacity asynchronously.',
'账号自检': 'Account self-check',
+121
View File
@@ -6,6 +6,11 @@ import { legacyGuideEnglishMessages } from './legacy-guide-messages'
export const messages = {
'zh-CN': {
'common.loading': '加载中...',
'poolQuota.claudeCode.window5h': '5H',
'poolQuota.claudeCode.weekly': '周',
'poolQuota.claudeCode.weeklySonnet': '周 Sonnet',
'poolQuota.claudeCode.weeklyFable': '周 Fable',
'poolQuota.claudeCode.unknownWindow': '额度',
'common.confirmRead': '确认已读',
'common.confirming': '确认中...',
'common.copy': '复制',
@@ -346,6 +351,45 @@ export const messages = {
'nav.usageStats': '使用统计',
'nav.operations': '运维总览',
'nav.userStats': '用户分析',
'userStats.title': '用户统计',
'userStats.description': '按用户或当前用户组成员查看排行榜与使用趋势',
'userStats.scope.placeholder': '统计维度',
'userStats.scope.user': '按用户',
'userStats.ungrouped': '未分组',
'userStats.scope.userGroup': '按用户组',
'userStats.search.user': '搜索用户',
'userStats.search.userGroup': '搜索用户组',
'userStats.select.user': '选择用户',
'userStats.select.userGroup': '选择用户组',
'userStats.compare.placeholder': '对比对象(可选)',
'userStats.compare.none': '不对比',
'userStats.leaderboard.user': '用户排行榜',
'userStats.leaderboard.userGroup': '用户组排行榜',
'userStats.pagination.summary': '共 {total} 项 · 第 {page} 页',
'userStats.pagination.previous': '上一页',
'userStats.pagination.next': '下一页',
'userStats.summary.user': '用户摘要',
'userStats.summary.userGroup': '用户组摘要',
'userStats.selectPrompt': '请选择统计对象',
'userStats.members.current': '当前成员',
'userStats.members.active': '活跃成员',
'userStats.memberLeaderboard': '组内成员排行',
'userStats.trend.user': '用户使用趋势',
'userStats.trend.userGroup': '用户组使用趋势',
'userStats.comparisonTrend.user': '用户对比趋势',
'userStats.comparisonTrend.userGroup': '用户组对比趋势',
'userStats.chart.current': '当前对象',
'userStats.chart.comparison': '对比对象',
'stats.metric.placeholder': '指标',
'stats.metric.requests': '请求数',
'stats.metric.tokens': 'Tokens',
'stats.metric.cost': '成本',
'stats.metric.errorRate': '错误率',
'stats.empty.title': '暂无数据',
'stats.empty.description': '当前时间范围内没有统计结果',
'stats.column.rank': '排名',
'stats.column.name': '名称',
'stats.column.members': '成员',
'nav.costAnalysis': '成本分析',
'nav.performanceAnalysis': '性能分析',
'nav.userManagement': '用户管理',
@@ -452,6 +496,11 @@ export const messages = {
},
'en-US': {
'common.loading': 'Loading...',
'poolQuota.claudeCode.window5h': '5H',
'poolQuota.claudeCode.weekly': 'Weekly',
'poolQuota.claudeCode.weeklySonnet': 'Weekly Sonnet',
'poolQuota.claudeCode.weeklyFable': 'Weekly Fable',
'poolQuota.claudeCode.unknownWindow': 'Quota',
'common.confirmRead': 'Confirm read',
'common.confirming': 'Confirming...',
'common.copy': 'Copy',
@@ -792,6 +841,45 @@ export const messages = {
'nav.usageStats': 'Usage stats',
'nav.operations': 'Operations',
'nav.userStats': 'User analysis',
'userStats.title': 'User statistics',
'userStats.description': 'View leaderboards and usage trends by user or current user-group membership',
'userStats.scope.placeholder': 'Statistics scope',
'userStats.scope.user': 'By user',
'userStats.ungrouped': 'Ungrouped',
'userStats.scope.userGroup': 'By user group',
'userStats.search.user': 'Search users',
'userStats.search.userGroup': 'Search user groups',
'userStats.select.user': 'Select a user',
'userStats.select.userGroup': 'Select a user group',
'userStats.compare.placeholder': 'Comparison (optional)',
'userStats.compare.none': 'No comparison',
'userStats.leaderboard.user': 'User leaderboard',
'userStats.leaderboard.userGroup': 'User-group leaderboard',
'userStats.pagination.summary': '{total} items · Page {page}',
'userStats.pagination.previous': 'Previous',
'userStats.pagination.next': 'Next',
'userStats.summary.user': 'User summary',
'userStats.summary.userGroup': 'User-group summary',
'userStats.selectPrompt': 'Select a statistics entity',
'userStats.members.current': 'Current members',
'userStats.members.active': 'Active members',
'userStats.memberLeaderboard': 'Group member leaderboard',
'userStats.trend.user': 'User usage trend',
'userStats.trend.userGroup': 'User-group usage trend',
'userStats.comparisonTrend.user': 'User comparison trend',
'userStats.comparisonTrend.userGroup': 'User-group comparison trend',
'userStats.chart.current': 'Current entity',
'userStats.chart.comparison': 'Comparison entity',
'stats.metric.placeholder': 'Metric',
'stats.metric.requests': 'Requests',
'stats.metric.tokens': 'Tokens',
'stats.metric.cost': 'Cost',
'stats.metric.errorRate': 'Error rate',
'stats.empty.title': 'No data',
'stats.empty.description': 'No statistics are available for the current time range',
'stats.column.rank': 'Rank',
'stats.column.name': 'Name',
'stats.column.members': 'Members',
'nav.costAnalysis': 'Cost analysis',
'nav.performanceAnalysis': 'Performance analysis',
'nav.userManagement': 'Users',
@@ -1380,6 +1468,27 @@ const legacyExactEnglishMessages: Record<string, string> = {
'暂无选项': 'No options',
'用户批量操作': 'User batch actions',
'按当前选择批量调整用户状态、角色和额度': 'Batch update user status, role, and quota for the current selection',
'按当前选择批量调整用户状态、角色、额度和钱包余额': 'Batch update user status, role, quota, and wallet balances for the current selection',
'调整余额': 'Adjust balance',
'批量增加或扣减钱包余额': 'Add or deduct wallet balances in bulk',
'调整金额 (USD)': 'Adjustment amount (USD)',
'余额调整方式': 'Balance adjustment mode',
'增加': 'Add',
'扣减': 'Deduct',
'请输入大于 0 的有限金额': 'Enter a finite amount greater than 0',
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
'用户钱包不可用': 'User wallet is unavailable',
'当前为只读模式,无法批量调整用户钱包余额': 'Cannot adjust user wallet balances in read-only mode',
'余额调整结果未确认,批次已中止,请核对钱包后再重试': 'Balance adjustment result is uncertain; the batch stopped. Verify the wallet before retrying.',
'用户钱包更新结果未确认,批次已中止,请核对钱包后再重试': 'User wallet update result is uncertain; the batch stopped. Verify the wallet before retrying.',
'用户更新结果未确认,批次已中止,请核对后再重试': 'User update result is uncertain; the batch stopped. Verify the result before retrying.',
'读取用户状态失败,批次已中止,该用户未执行': 'Could not read user state; the batch stopped before processing this user.',
'因前序错误未执行': 'Not processed because an earlier operation failed',
'请核对余额后再重试,勿直接重试整批': 'Verify balances before retrying; do not retry the entire batch blindly.',
'查看批次中断详情': 'View interrupted batch details',
'已完成用户 ID': 'Completed user IDs',
'结果待核对用户 ID': 'User IDs with uncertain results',
'尚未执行用户 ID': 'Unprocessed user IDs',
'影响用户:': 'Affected users:',
'目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。': 'Targets all users matching the current filters; the backend will resolve the selection again before execution.',
'目标为当前已勾选的用户,重复 ID 会自动去重。': 'Targets the currently selected users; duplicate IDs are deduplicated automatically.',
@@ -1722,6 +1831,8 @@ const legacyExactEnglishMessages: Record<string, string> = {
'模拟缓存模式': 'Simulated cache mode',
'启用后仅对 Kiro 请求模拟 prompt cache 读写计量。': 'When enabled, only Kiro requests simulate prompt cache read/write accounting.',
'Codex 指纹收敛': 'Codex fingerprint convergence',
'Codex 指纹收敛(推荐开启)': 'Codex fingerprint convergence (recommended)',
'开启后,Aether 会根据当前账号身份为出站请求统一生成并写入以下标识,使同一 Codex 账号在上游看来始终是同一设备与会话:\n\n• 请求头:installation_id、session_id、thread_id、window_id、turn_id 等\n• 请求体(Responses 格式):client_metadata 与 prompt_cache_key 中的对应字段\n\n关闭时保持客户端原始透传,不做任何改写。建议开启以提升上游指纹稳定性与会话连续性。': 'When enabled, Aether generates and writes consistent identifiers for outbound requests based on the current account identity, so the same Codex account always appears as the same device and session upstream:\n\n• Request headers: installation_id, session_id, thread_id, window_id, turn_id, etc.\n• Request body (Responses format): corresponding fields in client_metadata and prompt_cache_key\n\nWhen disabled, the original client values are passed through unchanged. Enabling is recommended for better upstream fingerprint stability and session continuity.',
'统一同一 Codex 身份的设备与会话标识;关闭时保持现有透传行为。': 'Use consistent device and session identifiers for the same Codex identity. When disabled, preserve the existing pass-through behavior.',
'请前往模块管理-敏感信息保护中配置详细规则。': 'Configure detailed rules in Modules - Sensitive information protection.',
'验证失败': 'Validation failed',
@@ -2149,7 +2260,10 @@ const legacyExactEnglishMessages: Record<string, string> = {
'账号不可用': 'Account unavailable',
'日额度': 'Daily quota',
'周额度': 'Weekly quota',
'月额度': 'Monthly quota',
'剩余额度': 'Remaining quota',
'预付额度': 'Prepaid credits',
'按需额度': 'On-demand credits',
'点击编辑优先级': 'Edit priority',
'点击编辑倍率': 'Edit multiplier',
'同步失败': 'Sync failed',
@@ -2965,6 +3079,9 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^(1[0-2]|[1-9])月$/u, match => new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2020, Number(match[1]) - 1, 1)))],
[/^总可用:(.+)$/u, match => `Total available: ${match[1]}`],
[/^余额:(.+)$/u, match => `Balance: ${match[1]}`],
[/^oauth\/usage 返回状态码 (\d+)$/u, match => `oauth/usage returned status ${match[1]}`],
[/^共 (\d+) 次机会$/u, match => `${match[1]} ${match[1] === '1' ? 'chance' : 'chances'} in total`],
[/^已处理 (\d+) 个 Key$/u, match => `Processed ${match[1]} ${match[1] === '1' ? 'key' : 'keys'}`],
[/^(.+) 秒$/u, match => `${translateLegacyText(match[1], 'en-US')}s`],
[/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`],
[/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`],
@@ -2974,6 +3091,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^确认(启用|禁用|删除|重置)((.+))$/u, match => `Confirm ${translateLegacyText(match[1], 'en-US').toLowerCase()} (${match[2]})`],
[/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
[/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
[/^批量操作中断:成功 (.+) 个,结果待确认 (.+) 个,尚未执行 (.+) 个$/u, match => `Batch interrupted: succeeded ${match[1]}, uncertain ${match[2]}, not processed ${match[3]}`],
[/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
[/^已选 (.+) 个$/u, match => `Selected ${match[1]} items`],
[/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`],
@@ -2981,6 +3099,9 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^(.+) 个用户$/u, match => `${match[1]} users`],
[/^有 (.+) 条格式错误$/u, match => `${match[1]} format errors`],
[/^还有 (.+) 条错误未显示$/u, match => `${match[1]} more errors hidden`],
[/^确定删除选中的 (\d+) 个模型吗?\n\n此操作不可撤销。$/u, match => `Delete the selected ${match[1]} ${match[1] === '1' ? 'model' : 'models'}?\n\nThis cannot be undone.`],
[/^成功删除 (\d+) 个模型$/u, match => `Deleted ${match[1]} ${match[1] === '1' ? 'model' : 'models'}`],
[/^(\d+) 个模型删除失败$/u, match => `Failed to delete ${match[1]} ${match[1] === '1' ? 'model' : 'models'}`],
[/^确定删除套餐「(.+)」吗?\n\n已有订单或权益的套餐不能删除,请改为停用。删除后无法恢复。$/u, match => `Delete plan "${match[1]}"?\n\nPlans with existing orders or entitlements cannot be deleted. Disable it instead. This cannot be undone.`],
[/^确定要删除映射 "(.+)" 吗?\n\n注意:这只会删除映射记录,不会删除 Google 上的实际文件。$/u, match => `Delete mapping "${match[1]}"?\n\nThis only deletes the mapping record and will not delete the actual file on Google.`],
[/^确认删除批次「(.+)」吗?删除后无法恢复。$/u, match => `Delete batch "${match[1]}"? This cannot be undone.`],
+5 -2
View File
@@ -2429,6 +2429,8 @@ function generateMockKeysForProvider(providerId: string, count: number = 2) {
})
}
const deletedProviderModelIds = new Set<string>()
// 为 provider 生成 models
function generateMockModelsForProvider(providerId: string) {
const provider = MOCK_PROVIDERS.find(p => p.id === providerId)
@@ -2607,7 +2609,7 @@ function generateMockModelsForProvider(providerId: string) {
)
}
return models
return models.filter(model => !deletedProviderModelIds.has(String(model.id)))
}
// ========== 注册动态路由 ==========
@@ -3355,9 +3357,10 @@ registerDynamicRoute('PATCH', '/api/admin/providers/:providerId/models/:modelId'
})
// 删除 Provider Model
registerDynamicRoute('DELETE', '/api/admin/providers/:providerId/models/:modelId', async (_config, _params) => {
registerDynamicRoute('DELETE', '/api/admin/providers/:providerId/models/:modelId', async (_config, params) => {
await delay()
requireAdmin()
deletedProviderModelIds.add(params.modelId)
return createMockResponse({ message: '删除成功(演示模式)' })
})
@@ -98,6 +98,9 @@ describe('CrossTabRefreshCoordinator', () => {
})
afterEach(() => {
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
localStorage.clear()
channelRegistry.clear()
})
@@ -139,7 +142,14 @@ describe('CrossTabRefreshCoordinator', () => {
second.destroy()
})
it('treats peer failure as a retry hint and verifies the session locally', async () => {
it.each([0, 1])('treats peer failure as a retry hint with %i ms between clock reads', async (clockStepMs) => {
vi.useFakeTimers()
let timestamp = Date.now()
vi.spyOn(Date, 'now').mockImplementation(() => {
const current = timestamp
timestamp += clockStepMs
return current
})
const refreshError = new Error('refresh failed')
const firstAttempt = createDeferred<string>()
const firstExecutor = vi
@@ -161,12 +171,14 @@ describe('CrossTabRefreshCoordinator', () => {
await Promise.resolve()
const secondRun = second.run(secondExecutor)
const firstOutcome = expect(firstRun).rejects.toThrow('refresh failed')
const secondOutcome = expect(secondRun).resolves.toBe('verified-in-second-tab')
firstAttempt.reject(refreshError)
await expect(firstRun).rejects.toThrow('refresh failed')
await expect(secondRun).resolves.toBe('verified-in-second-tab')
expect(firstExecutor.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(firstExecutor.mock.calls.length).toBeLessThanOrEqual(3)
await vi.runAllTimersAsync()
await firstOutcome
await secondOutcome
expect(firstExecutor).toHaveBeenCalledTimes(clockStepMs === 0 ? 3 : 2)
expect(secondExecutor).toHaveBeenCalledTimes(1)
first.destroy()
@@ -359,4 +359,30 @@ describe('providerKeyQuota', () => {
},
}, 'windsurf')).toBe('可用模型 3 个')
})
it('formats xAI weekly credits as remaining percent', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
provider_type: 'xai',
code: 'ok',
exhausted: false,
windows: [
{
code: 'usage',
scope: 'account',
used_ratio: 0.46,
remaining_ratio: 0.54,
},
{
code: 'prepaid',
remaining_value: 12.5,
},
],
},
},
}, 'xai')).toBe('剩余 54.0% | 预付剩余 12.5')
})
})
+8
View File
@@ -61,6 +61,14 @@ class MemoryCache {
this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1)
}
/** 删除匹配前缀的缓存及进行中请求,旧响应不再回填缓存。 */
deleteByPrefix(prefix: string): void {
const keys = new Set([...this.cache.keys(), ...this.inFlight.keys()])
for (const key of keys) {
if (key.startsWith(prefix)) this.delete(key)
}
}
/**
* 清空所有缓存
*/
+1
View File
@@ -5,6 +5,7 @@ export const OAUTH_ICONS: Record<string, string> = {
google: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
gemini_cli: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
grok: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="#111827"/><path d="M7 7L17 17M17 7L7 17" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/></svg>`,
xai: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="#111827"/><path d="M7 7L17 17M17 7L7 17" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/></svg>`,
}
// Default icon when provider type is not found
+25
View File
@@ -265,6 +265,29 @@ function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
return normalizeText(quota.label)
}
function getXaiQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
const usageText = getKiroQuotaText(quota)
if (usageText) parts.push(usageText)
const prepaid = getQuotaWindow(quota, 'prepaid')
if (typeof prepaid?.remaining_value === 'number') {
parts.push(`预付剩余 ${formatQuotaValue(prepaid.remaining_value)}`)
}
const onDemand = getQuotaWindow(quota, 'on_demand')
const onDemandRemaining = getQuotaWindowRemainingPercent(onDemand)
if (onDemandRemaining != null) {
const valueText = getQuotaWindowValueText(onDemand)
parts.push(`按需剩余 ${formatPercent(onDemandRemaining)}${valueText ? ` (${valueText})` : ''}`)
} else if (typeof onDemand?.remaining_value === 'number') {
parts.push(`按需剩余 ${formatQuotaValue(onDemand.remaining_value)}`)
}
if (parts.length > 0) return parts.join(' | ')
return normalizeText(quota.label)
}
function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'banned') {
@@ -452,6 +475,8 @@ export function getQuotaSnapshotFallbackText(
return getCodexQuotaText(quota)
case 'kiro':
return getKiroQuotaText(quota)
case 'xai':
return getXaiQuotaText(quota)
case 'grok':
return getGrokQuotaText(quota)
case 'windsurf':
+62 -5
View File
@@ -1071,6 +1071,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery'
import { useBatchSelection } from '@/composables/useBatchSelection'
import { useI18n } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
@@ -1395,6 +1396,8 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
// 保存前发出的详情读取不得覆盖这次保存返回的新配置。
providerDataRequestId += 1
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
@@ -1403,7 +1406,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
}
showSchedulingDialog.value = false
showAdvancedDialog.value = false
await loadOverview({ silent: true })
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
// --- Provider Selection ---
@@ -1694,12 +1697,14 @@ watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
const desktopColumnWidths = computed(() => {
@@ -2143,12 +2148,14 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
function canResetCycleStats(_key: PoolKeyDetail): boolean {
@@ -2267,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string |
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
}
function getClaudeCodeResetCredits(key: PoolKeyDetail) {
if (getQuotaSnapshotProviderType(key) !== 'claude_code') return null
return key.status_snapshot?.quota?.reset_credits
?? key.upstream_metadata?.claude_code?.reset_credits
?? null
}
function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
const claudeCredits = getClaudeCodeResetCredits(key)
if (claudeCredits) {
const claudeCount = getCodexResetCreditAvailableCount(claudeCredits)
return claudeCount === null ? null : formatCodexResetCreditCount(claudeCount)
}
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
? null
@@ -2275,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
}
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3)
return getVisibleCodexResetCreditItems(
getClaudeCodeResetCredits(key) ?? getCodexResetCredits(key),
undefined,
3,
)
.map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
}
@@ -3480,8 +3503,10 @@ function normalizeQuotaLabel(label: string): string {
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('月')) return '月'
if (normalized.includes('最低剩余')) return '最低'
if (normalized === '剩余' || normalized.includes('剩余')) return '剩余'
if (normalized === '额度') return '额度'
return normalized
}
@@ -3490,6 +3515,9 @@ function getQuotaProgressLabel(label: string): string {
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === '月') return '月'
if (label === '周额度') return '周'
if (label === '月额度') return '月'
if (label === '额度') return '额度'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
@@ -3498,7 +3526,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
@@ -3567,6 +3595,7 @@ function getQuotaLabelOrder(label: string): number {
if (label === 'Prompt') return 12
if (label === 'Flex') return 13
if (label === '剩余') return 14
if (label === '额度') return 14
if (label === '最低') return 15
if (label === '生图') return 16
if (label === '速率') return 17
@@ -3758,7 +3787,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'kiro') {
if (providerType === 'kiro' || providerType === 'xai') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
@@ -3772,12 +3801,13 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
: undefined
return [{
label: '剩余',
label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'),
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}]
}
@@ -3891,6 +3921,33 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'claude_code') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const windowPresentations: Record<string, { labelKey: MessageKey, sortOrder: number }> = {
'5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 },
weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 },
weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 },
weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 },
}
return (quota.windows ?? [])
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
const presentation = windowPresentations[String(window.code || '')]
return {
label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'),
sortOrder: presentation?.sortOrder ?? 9,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'gemini_cli') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []
+206 -186
View File
@@ -1,203 +1,221 @@
<template>
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<Button
variant="outline"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
<Tabs v-model="activeTab">
<TabsList class="mb-4">
<TabsTrigger value="accounts">
{{ t('用户账目', 'User accounts') }}
</TabsTrigger>
<TabsTrigger value="usage">
{{ t('使用统计', 'Usage statistics') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="accounts"
class="space-y-5"
>
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
<OverviewToolbar
:title="t('用户分析', 'User analysis')"
:range="range"
:preset="relativePreset"
:show-range="false"
:refresh-active="autoRefresh"
:refresh-title="refreshTitle"
presets-only
@update:range="setRange"
@refresh="toggleAutoRefresh"
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
size="sm"
:disabled="exporting"
@click="exportCsv('users', requestQuery)"
>
{{ t('查询', 'Search') }}
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
</OverviewToolbar>
<OverviewStatus
:error="error || exportError"
@retry="refreshAll"
/>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
<UserFinanceSummary
:summary="data?.data.summary"
:finance="data?.data.finance_summary"
:user-count="data?.data.summary?.user_count"
:active-user-count="data?.data.summary?.active_user_count"
/>
<UserReports :revision="revision" />
<TableCard
:title="t('用户账目', 'User accounts')"
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
class="relative min-w-0"
data-user-accounts
>
<template #actions>
<form
class="flex w-full items-center gap-2 md:w-auto"
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
>
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="search"
type="search"
:placeholder="t('搜索用户', 'Search users')"
:aria-label="t('搜索用户', 'Search users')"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
type="submit"
size="sm"
variant="outline"
class="h-8 text-xs"
>
{{ t('查询', 'Search') }}
</Button>
</form>
</template>
<p
v-if="query.search"
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
>
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
</p>
<div
class="min-w-0"
:aria-busy="loading"
>
<Table class="min-w-[1040px]">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
{{ t('用户', 'User') }}
</TableHead>
<SortableTableHead
v-for="column in columns"
:key="column.key"
class="h-12 font-semibold text-right"
:column-key="column.key"
:active-key="requestQuery.sort"
:direction="requestQuery.order"
default-direction="desc"
align="right"
@sort="sort"
>
{{ column.label }}
</SortableTableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('充值', 'Recharges') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('余额', 'Balance') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('最近使用', 'Last used') }}
</TableHead>
<TableHead class="h-12 font-semibold text-right">
{{ t('记录', 'Records') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in data?.data.items || []"
:key="user.user_id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
>
<TableCell class="max-w-64 py-4 text-xs">
<button
type="button"
class="break-words hover:text-primary hover:underline"
@click="accountUser = user"
>
{{ user.username }}
</button>
<span
v-if="!user.is_active"
class="ml-2 text-xs text-muted-foreground"
>{{ t('停用', 'Disabled') }}</span>
<p class="mt-0.5 break-words text-xs text-muted-foreground">
{{ user.email }}
</p>
</TableCell>
<TableCell
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
:title="user.billable_amount?.value ?? ''"
>
{{ money(user.billable_amount) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.request_count) }}
</TableCell>
<TableCell class="py-4 text-right text-xs tabular-nums">
{{ count(user.total_tokens) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.recharge_amount) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
{{ money(user.finance?.wallet_balance) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
{{ timestamp(user.last_used_at, range.timezone) }}
</TableCell>
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
<Button
type="button"
variant="ghost"
size="icon"
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
@click="accountUser = user"
>
<ReceiptText class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!data?.data.items.length">
<TableCell
colspan="8"
class="py-12 text-center text-muted-foreground"
>
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<template #pagination>
<Pagination
v-if="data"
:total="data.data.total"
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
:page-size="requestQuery.limit"
:page-size-options="[25, 50, 100]"
@update:current="changePage"
@update:page-size="changePageSize"
/>
</template>
</TableCard>
<UserAccountDrawer
v-if="accountUser"
:key="accountUser.user_id"
:user="accountUser"
:timezone="range.timezone"
@close="accountUser = null"
/>
</TabsContent>
<TabsContent value="usage">
<UserUsageStats v-if="activeTab === 'usage'" />
</TabsContent>
</Tabs>
</main>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { Download, Search, ReceiptText } from 'lucide-vue-next'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { overviewApi } from '@/api/overview'
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
@@ -210,6 +228,8 @@ import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
import { useOverviewExport } from '@/features/overview/useOverviewExport'
import { useOverviewI18n } from '@/features/overview/i18n'
import { count, money, timestamp } from '@/features/overview/format'
const UserUsageStats = defineAsyncComponent(() => import('@/features/overview/users/UserUsageStats.vue'))
const activeTab = ref('accounts')
const { t } = useOverviewI18n()
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
@@ -340,12 +340,24 @@ vi.mock('@/components/ui/refresh-button.vue', async () => {
})
vi.mock('@/features/pool/components/PoolSchedulingDialog.vue', async () => {
const { defineComponent } = await import('vue')
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'PoolSchedulingDialogStub',
setup() {
return () => null
props: {
providerId: { type: String, required: true },
currentConfig: { type: Object, default: null },
},
emits: ['saved'],
setup(props, { emit }) {
return () => h('button', {
'data-testid': 'save-pool-scheduling',
'data-reserve': String(props.currentConfig?.reserve_minimum_quota ?? false),
onClick: () => emit('saved', {
id: props.providerId,
pool_advanced: { ...props.currentConfig, reserve_minimum_quota: false },
}),
}, '保存调度测试')
},
}),
}
@@ -1278,4 +1290,80 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(disabledRoot.querySelector('[data-testid="pool-demand-metrics-button"]')).toBeNull()
})
it('reloads account scheduling state immediately after disabling minimum quota reserve', async () => {
const key = createPoolKey('codex', {
scheduling_status: 'blocked',
scheduling_reason: 'account_quota_exhausted',
scheduling_label: '额度耗尽',
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(key))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('tbody')?.textContent).toContain('额度耗尽')
endpointMocks.listPoolKeys.mockClear()
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage({
...key,
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
}))
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
expect(endpointMocks.listPoolKeys).toHaveBeenCalledWith(
'codex-provider', expect.anything(), { cacheTtlMs: 0 },
)
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
})
it('shows 17% remaining without marking a schedulable account exhausted when reserve is enabled', async () => {
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey('codex', {
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
status_snapshot: {
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'codex',
windows: [{ code: '5h', used_ratio: 0.83, remaining_ratio: 0.17 }],
},
oauth: { code: 'none' },
},
})))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('17.0%')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
})
it('does not replace saved scheduling settings with an older provider detail response', async () => {
let resolveOldProvider!: (provider: ReturnType<typeof createProvider>) => void
endpointMocks.getProvider.mockReturnValue(new Promise((resolve) => { resolveOldProvider = resolve }))
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey()))
const root = mountPoolManagement()
await settle()
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
resolveOldProvider(createProvider('codex', { pool_advanced: { reserve_minimum_quota: true } }))
await settle()
expect(root.querySelector('[data-testid="save-pool-scheduling"]')?.getAttribute('data-reserve')).toBe('false')
})
})
+12
View File
@@ -527,6 +527,8 @@ const filteredRecords = computed(() => {
records = records.filter(record => hasUsageFallback(record))
} else if (filterStatus.value === 'has_retry') {
records = records.filter(record => record.has_retry === true)
} else if (filterStatus.value === 'has_skipped_candidate') {
records = records.filter(record => record.has_skipped_candidate === true)
}
}
@@ -735,6 +737,10 @@ async function pollActiveRequests() {
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier
: null
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
? update.response_model
: null
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
@@ -1307,6 +1313,7 @@ function handleDetailRequestState(update: {
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1449,6 +1456,11 @@ function handleDetailRequestState(update: {
? update.actualServiceTier
: null
}
if ('responseModel' in update) {
record.response_model = typeof update.responseModel === 'string'
? update.responseModel
: null
}
if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) {