mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
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:
@@ -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,
|
||||
|
||||
+166
@@ -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'
|
||||
|
||||
+182
@@ -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))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+265
@@ -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',
|
||||
])
|
||||
|
||||
|
||||
+166
@@ -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">-></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({
|
||||
|
||||
+25
-21
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user