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
@@ -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)
})
},
}
}