fix: restore security hardening compatibility and validation

Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
elky
2026-09-07 21:14:27 +08:00
parent a5c3699ae9
commit a90d564931
191 changed files with 6785 additions and 1643 deletions
@@ -163,6 +163,7 @@ async function execute(action: string): Promise<string> {
if (!turnstile || !container || !turnstile.execute) {
throw new Error('Turnstile unavailable')
}
const execute = turnstile.execute.bind(turnstile)
clearWidget()
@@ -191,7 +192,7 @@ async function execute(action: string): Promise<string> {
},
})
widgetId.value = id
turnstile.execute(id)
execute(id)
})
}
@@ -1189,7 +1189,7 @@ function setEmbeddingEnabled(enabled: boolean) {
setConfigField('embedding', undefined)
if (form.value.config?.model_type === 'embedding') setConfigField('model_type', undefined)
if (Array.isArray(form.value.config?.api_formats)
&& form.value.config.api_formats.every((format) => embeddingApiFormats.includes(String(format)))) {
&& form.value.config.api_formats.every((format) => embeddingApiFormats.some((supportedFormat) => supportedFormat === String(format)))) {
setConfigField('api_formats', undefined)
}
}
@@ -567,7 +567,7 @@ import { getGlobalModelRoutingPreview } from '@/api/global-models'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
import type { TieredPricingConfig, PricingTier, ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import type { ModelProviderReference, TieredPricingConfig, PricingTier, ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import type { RoutingProviderInfo } from '@/api/global-models'
const props = withDefaults(defineProps<Props>(), {
@@ -578,9 +578,9 @@ const emit = defineEmits<{
'editModel': [model: GlobalModelResponse]
'toggleModelStatus': [model: GlobalModelResponse]
'addProvider': []
'editProvider': [provider: Record<string, unknown>]
'deleteProvider': [provider: Record<string, unknown>]
'toggleProviderStatus': [provider: Record<string, unknown>]
'editProvider': [provider: ModelProviderReference]
'deleteProvider': [provider: ModelProviderReference]
'toggleProviderStatus': [provider: ModelProviderReference]
'refreshModel': []
'linkProvider': [providerId: string]
'linkProviders': [providerIds: string[]]
@@ -246,8 +246,8 @@ const activeTierKey = ref('')
const processingTierEntries = computed<ProcessingTierEntry[]>(() => {
const processingTiers = props.pricing?.processing_tiers
if (!isRecord(processingTiers)) return []
const labels = new Map(KNOWN_PROCESSING_TIERS.map(entry => [entry.key, entry.label]))
const order = new Map(KNOWN_PROCESSING_TIERS.map((entry, index) => [entry.key, index]))
const labels = new Map<string, string>(KNOWN_PROCESSING_TIERS.map(entry => [entry.key, entry.label]))
const order = new Map<string, number>(KNOWN_PROCESSING_TIERS.map((entry, index) => [entry.key, index]))
return Object.entries(processingTiers)
.filter((entry): entry is [string, ProcessingTierPricingConfig] => (
isRecord(entry[1]) && processingPricingHasFacts(entry[1])
@@ -728,8 +728,8 @@ const activeProcessingTierUsesMultiplier = computed(() => (
const compactProcessingTierOptions = computed<CompactProcessingTierOption[]>(() => (
COMPACT_PROCESSING_TIERS.map(option => ({
...option,
accessibleLabel: [option.group, option.label, 'detail' in option ? option.detail : null]
.filter((part): part is string => Boolean(part))
accessibleLabel: ['group' in option ? option.group : null, option.label, 'detail' in option ? option.detail : null]
.filter((part) => Boolean(part))
.join(' · '),
...(processingTierMultiplierDrafts[option.key] ?? {
enabled: false,
@@ -1335,7 +1335,7 @@ function processingTierDisplayLabel(key: string): string {
'group' in compactTier ? compactTier.group : null,
compactTier.label,
'detail' in compactTier ? compactTier.detail : null,
].filter((part): part is string => Boolean(part)).join(' · ')
].filter((part) => Boolean(part)).join(' · ')
}
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
}
@@ -744,7 +744,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
const result = await batchActionPoolKeys(props.providerId, {
key_ids: batch,
action: selectedAction.value as 'enable' | 'disable' | 'clear_proxy' | 'set_proxy' | 'update_settings',
...(payload ? { payload } : {}),
...(payload ? { payload: { ...payload } } : {}),
})
successCount += result.affected
} catch (err) {
@@ -708,7 +708,7 @@ async function handleSave() {
try {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const latestAdvanced = latestProvider.pool_advanced
const existingPoolAdvanced = mergePoolAdvancedPatch(latestAdvanced, {})
const latestScoreRules = typeof existingPoolAdvanced.score_rules === 'object'
&& existingPoolAdvanced.score_rules !== null
@@ -192,7 +192,7 @@ const chartWidth = 640
const chartHeight = 220
const yTickRatios = [0, 0.25, 0.5, 0.75, 1]
const latest = computed(() => props.samples.at(-1) ?? null)
const latest = computed(() => props.samples[props.samples.length - 1] ?? null)
const maxChartValue = computed(() => {
const maxValue = props.samples.reduce((max, sample) => {
@@ -108,7 +108,7 @@ function metricForGroup(
const cycleMetricRows = computed(() => {
const smallGroup = props.cycleGroups.length > 1 ? props.cycleGroups[0] : undefined
const largeGroup = props.cycleGroups.at(-1)
const largeGroup = props.cycleGroups[props.cycleGroups.length - 1]
if (!largeGroup) return []
return CYCLE_METRIC_KEYS.map((key) => {
@@ -92,7 +92,7 @@
:title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)"
@click="triggerAction(action.event)"
>
<component
:is="action.icon"
@@ -253,7 +253,7 @@
:title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)"
@click="triggerAction(action.event)"
>
<component
:is="action.icon"
@@ -413,6 +413,17 @@ const emit = defineEmits<{
refresh: []
}>()
function triggerAction(event: HeaderActionEvent) {
const handlers = {
import: () => emit('import'),
scheduling: () => emit('scheduling'),
viewProvider: () => emit('viewProvider'),
demandMetrics: () => emit('demandMetrics'),
advanced: () => emit('advanced'),
}
handlers[event]()
}
const { legacyT } = useI18n()
const providerModel = computed({
@@ -880,7 +880,7 @@ async function handleSave() {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const latestAdvanced = latestProvider.pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
})
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
buildPoolManagementQueryPatch,
readPoolManagementViewState,
resolvePoolManagementPageAfterLoad,
@@ -71,8 +72,9 @@ describe('poolManagementState', () => {
})
it('falls back to storage when query is missing', () => {
writePoolManagementViewState(
{
storage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify({
providerId: 'provider-c',
search: 'stored only',
status: 'active',
@@ -81,8 +83,7 @@ describe('poolManagementState', () => {
sortBy: 'last_used_at',
sortOrder: 'asc',
statsMode: 'account_total',
},
storage,
}),
)
const state = readPoolManagementViewState({}, storage)
@@ -23,7 +23,8 @@ export function mergePoolKeyQuotaSnapshots(
...(quotaSnapshot ? {
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
oauth: key.status_snapshot?.oauth ?? { code: 'none' },
account: key.status_snapshot?.account ?? { code: 'unknown', blocked: false },
quota: quotaSnapshot,
},
} : {}),
@@ -17,70 +17,8 @@ import { executeFieldHook } from './field-hooks'
// ==================== 类型定义 ====================
/** 后端 credentials_schema 的类型 */
export interface CredentialsSchema {
type: 'object'
properties: Record<string, SchemaProperty>
required?: string[]
'x-field-groups'?: SchemaFieldGroup[]
'x-auth-type'?: string
'x-auth-method'?: string
'x-validation'?: SchemaValidation[]
'x-quota-divisor'?: number | null
'x-currency'?: string
'x-default-base-url'?: string
'x-balance-extra-format'?: BalanceExtraFormat[]
'x-field-hooks'?: Record<string, { action: string; target: string }>
}
interface SchemaProperty {
type: string
title?: string
description?: string
'x-sensitive'?: boolean
'x-input-type'?: string
'x-default-value'?: string
'x-help'?: string
}
interface SchemaFieldGroup {
fields: string[]
layout?: 'inline' | 'vertical'
'x-flex'?: Record<string, number>
'x-help'?: string
}
interface SchemaValidation {
type: 'required' | 'any_required' | 'conditional_required'
fields?: string[]
message: string
/** conditional_required: 当此字段有值时 */
if?: string
/** conditional_required: 除非此字段有值 */
unless?: string
/** conditional_required: 则这些字段必填 */
then?: string[]
}
interface BalanceExtraFormat {
label: string
type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry'
/** window_limit: extra 中的字段名 */
source?: string
/** window_limit: 单位除数 */
unit_divisor?: number
/** daily_quota / weekly_spent: limit 字段名 */
source_limit?: string
/** daily_quota: remaining 字段名 */
source_remaining?: string
/** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */
source_start_date?: string
/** weekly_spent: spent 字段名 */
source_spent?: string
/** weekly_spent: resets_at 字段名 */
source_resets_at?: string
/** monthly_expiry: 到期日期字段名 */
source_end_date?: string
}
export type { CredentialsSchema } from '@/api/providerCredentials'
import type { CredentialsSchema, BalanceExtraFormat, SchemaProperty, SchemaFieldGroup } from '@/api/providerCredentials'
// ==================== Schema -> 表单字段 ====================
@@ -105,7 +105,9 @@ export function buildProxyConfig(formData: Record<string, unknown>): { proxy_nod
if (!formData.proxy_enabled || !formData.proxy_node_id) {
return {}
}
return { proxy_node_id: formData.proxy_node_id }
return typeof formData.proxy_node_id === 'string'
? { proxy_node_id: formData.proxy_node_id }
: {}
}
/**
@@ -205,7 +205,7 @@
v-if="!isEndpointConfigReadOnly"
v-model:open="endpointRulesExpanded[endpoint.id]"
>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<!-- 有规则时显示可折叠的触发器 -->
<CollapsibleTrigger
v-if="getTotalRulesCount(endpoint) > 0"
@@ -236,7 +236,18 @@
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<div class="flex w-full flex-wrap items-center justify-end gap-1 sm:w-auto">
<Button
v-if="getTotalRulesCount(endpoint) > 0"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="legacyT('查看已保存规则的原值')"
@click="revealRulesEndpointId = endpoint.id"
>
<Eye class="w-3 h-3 mr-1" />
{{ legacyT('查看原值') }}
</Button>
<Button
v-if="hasRulePanelChanges(endpoint)"
variant="ghost"
@@ -1016,6 +1027,12 @@
</template>
</Dialog>
<EndpointRulesRevealDialog
:model-value="modelValue && revealRulesEndpointId !== null"
:endpoint-id="revealRulesEndpointId"
@update:model-value="revealRulesEndpointId = null"
/>
<!-- 删除端点确认弹窗 -->
<AlertDialog
:model-value="deleteConfirmOpen"
@@ -1052,13 +1069,14 @@ import {
PopoverTrigger,
PopoverContent,
} from '@/components/ui'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft } from 'lucide-vue-next'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft, Eye } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import { useI18n } from '@/i18n'
import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import EndpointRulesRevealDialog from './EndpointRulesRevealDialog.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from './endpoint-default-paths'
import {
@@ -1444,6 +1462,7 @@ function handleBodyRuleDragEnd(endpointId: string) {
// 状态
const addingEndpoint = ref(false)
const savingEndpointId = ref<string | null>(null)
const revealRulesEndpointId = ref<string | null>(null)
const resettingDefaultRulesEndpointId = ref<string | null>(null)
const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
@@ -1544,7 +1563,7 @@ function validateJsonCondition(rule: Record<string, unknown>, label: string, ind
if (!isJsonObject(raw)) return formatJsonRuleError(label, index, 'condition 必须是对象')
const shapeError = validateJsonConditionShape(raw, formatJsonRuleFieldLabel(label, index, 'condition'))
if (shapeError) return shapeError
const editable = conditionToEditable(raw as BodyRule['condition'])
const editable = conditionToEditable(raw as NonNullable<BodyRule['condition']> & Record<string, unknown>)
const err = validateEditableCondition(editable)
return err ? formatJsonRuleError(label, index, err) : null
}
@@ -3307,7 +3326,8 @@ onMounted(() => {
})
// 监听 props 变化
watch(() => props.modelValue, (open) => {
watch(() => [props.modelValue, props.provider?.id] as const, ([open]) => {
revealRulesEndpointId.value = null
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
responseRuleSelectOpen.value = {}
@@ -3338,6 +3358,8 @@ watch(() => props.modelValue, (open) => {
} else {
// 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
endpointEditStates.value = {}
localEndpoints.value = []
}
}, { immediate: true })
@@ -3416,7 +3438,16 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
const submittedState = JSON.stringify(state)
const submittedJsonDraft = endpointRulesJsonDraft.value[endpoint.id]
const updatedEndpoint = await updateEndpoint(endpoint.id, payload)
if (endpointEditStates.value[endpoint.id] === state) {
localEndpoints.value = localEndpoints.value.map(current => current.id === endpoint.id ? updatedEndpoint : current)
if (JSON.stringify(state) === submittedState
&& endpointRulesJsonDraft.value[endpoint.id] === submittedJsonDraft) {
resetEndpointChanges(updatedEndpoint)
}
}
success(legacyT('端点已更新'))
emit('endpointUpdated')
} catch (error: unknown) {
@@ -0,0 +1,89 @@
<template>
<Dialog
:model-value="modelValue"
:title="legacyT('查看规则原值')"
size="2xl"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">
{{ legacyT('仅展示已保存的规则,可能包含密钥;不会覆盖未保存的编辑。关闭后清除明文。') }}
</p>
<div
v-if="loading"
role="status"
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
{{ legacyT('加载中...') }}
</div>
<p
v-else-if="failed"
role="alert"
class="text-sm text-destructive"
>
{{ legacyT('加载规则失败,请关闭后重试') }}
</p>
<Textarea
v-else-if="rulesJson"
:model-value="rulesJson"
:aria-label="legacyT('原始规则 JSON')"
readonly
spellcheck="false"
class="min-h-[320px] font-mono text-xs leading-relaxed"
/>
</div>
<template #footer>
<Button
variant="outline"
@click="emit('update:modelValue', false)"
>
{{ legacyT('关闭') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { Button, Dialog, Textarea } from '@/components/ui'
import { revealEndpointRules } from '@/api/endpoints'
import { useI18n } from '@/i18n'
const props = defineProps<{
modelValue: boolean
endpointId: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { legacyT } = useI18n()
const rulesJson = ref('')
const loading = ref(false)
const failed = ref(false)
watch(() => [props.modelValue, props.endpointId] as const, async ([open, endpointId], _previous, onCleanup) => {
rulesJson.value = ''
failed.value = false
loading.value = false
if (!open || !endpointId) return
const controller = new AbortController()
onCleanup(() => {
controller.abort()
rulesJson.value = ''
})
loading.value = true
try {
const rules = await revealEndpointRules(endpointId, controller.signal)
if (!controller.signal.aborted) {
rulesJson.value = JSON.stringify(rules, null, 2)
}
} catch {
if (!controller.signal.aborted) failed.value = true
} finally {
if (!controller.signal.aborted) loading.value = false
}
}, { immediate: true })
</script>
@@ -239,15 +239,15 @@ async function loadRelated() {
related_limit: 8,
per_item_limit: 100
}
if (!props.isAdmin && target.source.kind === 'provider') {
const dimension = target.source.kind
if (!props.isAdmin && dimension === 'provider') {
throw new Error('公开健康监控不支持 provider 详情')
}
const data = props.isAdmin
? await getHealthRelatedMonitor(params)
: await getPublicHealthRelatedMonitor({
...params,
dimension: target.source.kind
})
: dimension === 'provider'
? null
: await getPublicHealthRelatedMonitor({ ...params, dimension })
if (seq === requestSeq) {
related.value = data
}
@@ -117,7 +117,7 @@
<div
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'oauth' ? '' : undefined"
:inert="mode !== 'oauth' ? true : undefined"
:aria-hidden="mode !== 'oauth'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
@@ -612,7 +612,7 @@
v-if="isClaudeCodeProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'cookie' ? '' : undefined"
:inert="mode !== 'cookie' ? true : undefined"
:aria-hidden="mode !== 'cookie'"
>
<label
@@ -648,7 +648,7 @@
<div
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'import' ? '' : undefined"
:inert="mode !== 'import' ? true : undefined"
:aria-hidden="mode !== 'import'"
>
<div
@@ -780,7 +780,7 @@
v-if="isCodexProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'agent_identity' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'agent_identity' ? '' : undefined"
:inert="mode !== 'agent_identity' ? true : undefined"
:aria-hidden="mode !== 'agent_identity'"
>
<Textarea
@@ -94,7 +94,7 @@
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">启用代理</span>
<Switch
:model-value="formData[group.toggleKey] || false"
:model-value="formData[group.toggleKey] === true"
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
/>
</div>
@@ -107,7 +107,7 @@
>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
:model-value="formData.proxy_node_id || ''"
:model-value="stringFieldValue('proxy_node_id')"
trigger-class="h-8"
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
/>
@@ -146,7 +146,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -156,7 +156,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -182,7 +182,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -192,7 +192,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -201,7 +201,7 @@
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
@update:model-value="handleFieldChange(field.key, $event)"
>
<SelectTrigger>
@@ -221,7 +221,7 @@
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.placeholder"
rows="3"
@update:model-value="handleFieldChange(field.key, $event)"
@@ -417,6 +417,11 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
function stringFieldValue(key: string): string {
const value = formData.value[key]
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
@@ -439,11 +444,11 @@ const currentSchema = computed<CredentialsSchema | null>(() => {
if (selectedAuthType.value && arch.supported_auth_types.length > 1) {
const authType = arch.supported_auth_types.find((t) => t.type === selectedAuthType.value)
if (authType?.credentials_schema) {
return authType.credentials_schema as CredentialsSchema
return authType.credentials_schema
}
}
return (arch?.credentials_schema as CredentialsSchema) ?? null
return arch?.credentials_schema ?? null
})
// 表单是否可以验证(必填字段已填写)
@@ -464,7 +469,7 @@ const canVerify = computed(() => {
const error = validateFromSchema(schema, dataToValidate)
if (error) return false
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
return !!effectiveBaseUrl
})
@@ -503,6 +508,7 @@ function handleAuthTypeChange() {
}
function handleFieldChange(fieldKey: string, value: unknown) {
formData.value[fieldKey] = value
formChanged.value = true
// 执行 schema 定义的字段钩子
@@ -533,7 +539,7 @@ function resetFormData() {
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
data[key] = prop['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
@@ -575,7 +581,7 @@ async function handleVerify() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -672,7 +678,7 @@ async function handleSave() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -129,7 +129,7 @@
<ProviderKeyIdentityBlock
:api-key="key"
:masked-secret-label="getProviderMaskedSecretLabel(key, provider.provider_type)"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type ?? '') : null"
:oauth-plan-class="key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : ''"
:oauth-org-badge="getOAuthOrgBadge(key)"
:kiro-subscription-label="shouldShowKiroSubscriptionBadge(key) ? getKiroSubscriptionBadgeLabel(key) : null"
@@ -153,7 +153,7 @@
</div>
<ProviderKeyActionCluster
:api-key="key"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
:recoverable="isKeyRecoverable(key)"
:recover-title="getRecoverKeyTitle(key)"
:circuit-breaker-title="getKeyCircuitBreakerTitle(key)"
@@ -161,7 +161,7 @@
:health-score-bar-class="getHealthScoreBarColor(key.health_score || 0)"
:health-score-text-class="getHealthScoreColor(key.health_score || 0)"
:proxy-popover-open="proxyPopoverOpenKeyId === key.id"
:proxy-node-name="getKeyProxyNodeName(key)"
:proxy-node-name="getKeyProxyNodeName(key) ?? undefined"
:saving-proxy="savingProxyKeyId === key.id"
:toggling="togglingKeyId === key.id"
@recover="handleRecoverKey(key)"
@@ -847,7 +847,7 @@
v-if="open && oauthAccountDialogOpen && provider"
:open="oauthAccountDialogOpen"
:provider-id="provider.id"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
@close="oauthAccountDialogOpen = false"
@saved="handleKeyChanged"
/>
@@ -910,10 +910,10 @@
<AntigravityQuotaDialog
v-if="antigravityQuotaDialogKey"
:open="antigravityQuotaDialogOpen"
:metadata="antigravityQuotaDialogKey.upstream_metadata"
:metadata="antigravityQuotaDialogKey.upstream_metadata ?? null"
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
:key-name="antigravityQuotaDialogKey.name || legacyT('未命名密钥')"
:provider-id="providerId"
:provider-id="providerId ?? undefined"
:key-id="antigravityQuotaDialogKey.id"
@update:open="antigravityQuotaDialogOpen = $event"
/>
@@ -1554,7 +1554,7 @@ async function downloadRefreshToken(key: EndpointAPIKey) {
try {
const data = await exportKey(key.id)
const providerType = provider.value?.provider_type || 'unknown'
const safeName = (data.email || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const safeName = ((typeof data.email === 'string' && data.email) || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -1693,7 +1693,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
title: legacyT('清除账号异常标记'),
message: formatClearOAuthInvalidConfirmMessage(key),
confirmText: legacyT('确认清除'),
variant: 'default',
variant: 'info',
})
if (!confirmed) return
@@ -2474,7 +2474,7 @@ function isKiroBannedKey(key: EndpointAPIKey): boolean {
}
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
function formatBanTimestamp(timestamp: number | undefined): string {
function formatBanTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString(getI18nLocale(), {
@@ -2516,7 +2516,7 @@ function formatKiroUsage(value: number | undefined): string {
}
// 格式化 Kiro 重置时间
function formatKiroResetTime(timestamp: number | undefined): string {
function formatKiroResetTime(timestamp: number | null | undefined): string {
if (!timestamp) return ''
// timestamp 可能是毫秒或秒,需要判断
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000
@@ -2568,7 +2568,7 @@ function shouldShowKiroSubscriptionBadge(key: EndpointAPIKey): boolean {
const kiroLabel = getKiroSubscriptionBadgeLabel(key)
if (!kiroLabel) return false
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type)
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type ?? '')
if (!oauthPlanLabel) return true
return oauthPlanLabel.trim().toLowerCase() !== kiroLabel.trim().toLowerCase()
@@ -2887,6 +2887,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
}
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
const providerId = props.providerId
if (!providerId) return
antigravityQuotaDialogKey.value = key
antigravityQuotaDialogOpen.value = true
@@ -2895,7 +2897,8 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
if (refreshingQuota.value) return
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
const result = await refreshProviderQuota(providerId)
if (providerId !== props.providerId) return
applyQuotaResults(result.results)
// 更新弹窗引用的 key 数据
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
@@ -535,7 +535,7 @@ function buildSettingsPayload(
cache_ttl_minutes: source.cache_ttl_minutes,
max_probe_interval_minutes: source.max_probe_interval_minutes,
is_active: source.is_active,
note: source.note.trim() || null,
note: source.note?.trim() || null,
...((source.proxy_node_id || includeEmptyProxy)
? { proxy_node_id: source.proxy_node_id || null }
: {}),
@@ -658,4 +658,4 @@ async function submitImport(): Promise<void> {
importing.value = false
}
}
</script>
</script>
@@ -98,7 +98,7 @@
<div class="space-y-1.5">
<Label class="text-xs">代理节点</Label>
<ProxyNodeSelect
:model-value="settings.proxy_node_id"
:model-value="settings.proxy_node_id ?? ''"
trigger-class="h-10"
@update:model-value="updateSetting('proxy_node_id', $event)"
/>
@@ -106,7 +106,7 @@
<div class="space-y-1.5">
<Label class="text-xs">备注</Label>
<Input
:model-value="settings.note"
:model-value="settings.note ?? ''"
class="h-10"
placeholder="可选"
@update:model-value="updateSetting('note', String($event))"
@@ -186,4 +186,4 @@ function updateSetting<Key extends keyof ImportSettings>(
): void {
emit('update:settings', { ...props.settings, [key]: value })
}
</script>
</script>
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, ref, type App, type ComponentPublicInstance } from 'vue'
import EndpointFormDialog from '../EndpointFormDialog.vue'
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
const api = vi.hoisted(() => ({
createEndpoint: vi.fn(),
getDefaultBodyRules: vi.fn().mockResolvedValue({ body_rules: [] }),
updateEndpoint: vi.fn(),
deleteEndpoint: vi.fn(),
}))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/api/admin', () => ({ adminApi: { getApiFormats: vi.fn().mockResolvedValue({ formats: [] }) } }))
vi.mock('@/composables/useToast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() }) }))
vi.mock('@/stores/proxy-nodes', () => ({ useProxyNodesStore: () => ({ nodes: [], ensureLoaded: vi.fn() }) }))
vi.mock('../ProxyNodeSelect.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointConditionEditor.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointRulesRevealDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common/AlertDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = defineComponent({
inheritAttrs: false,
setup: (_props, { slots }) => () => h('div', [slots.default?.(), slots.footer?.()]),
})
return Object.fromEntries([
'Dialog', 'Button', 'Input', 'Textarea', 'Label', 'Badge', 'Select', 'SelectTrigger',
'SelectValue', 'SelectContent', 'SelectItem', 'Switch', 'Collapsible', 'CollapsibleTrigger',
'CollapsibleContent', 'Popover', 'PopoverTrigger', 'PopoverContent',
].map(name => [name, passthrough]))
})
interface DialogState {
localEndpoints: ProviderEndpoint[]
endpointEditStates: Record<string, unknown>
endpointRulesJsonDirty: Record<string, boolean>
endpointRulesJsonDraft: Record<string, string>
enterEndpointRulesJsonMode: (endpoint: ProviderEndpoint) => void
updateEndpointRulesJsonDraft: (endpointId: string, value: string) => void
getEndpointEditRules: (endpointId: string) => Array<{ value: string, retainValue: boolean }>
updateEndpointRuleField: (endpointId: string, index: number, field: 'value', value: string) => void
hasRulePanelChanges: (endpoint: ProviderEndpoint) => boolean
saveEndpoint: (endpoint: ProviderEndpoint) => Promise<void>
}
const endpoint: ProviderEndpoint = {
id: 'endpoint-1', provider_id: 'provider-1', provider_name: 'Custom', api_format: 'openai:chat',
base_url: 'https://example.test', is_active: true,
header_rules: [{ action: 'set', key: 'x-auth', value: '***', has_value: true }],
body_rules: [], config: {}, max_retries: 0, total_keys: 0, active_keys: 0,
created_at: '2026-09-07T00:00:00Z', updated_at: '2026-09-07T00:00:00Z',
}
const mounted: Array<{ app: App, root: HTMLElement }> = []
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
async function mountDialog() {
const props = reactive({
modelValue: true,
provider: { id: 'provider-1', provider_type: 'custom', name: 'Custom' } as ProviderWithEndpointsSummary,
endpoints: [structuredClone(endpoint)],
})
const component = ref<ComponentPublicInstance>()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(EndpointFormDialog, { ...props, ref: component }) }))
app.mount(root)
mounted.push({ app, root })
await settle()
const { setupState: state } = component.value!.$ as unknown as { setupState: DialogState }
return { props, state }
}
beforeEach(() => {
api.updateEndpoint.mockReset().mockResolvedValue(structuredClone(endpoint))
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule saving', () => {
it('resets dirty state from the saved projection and preserves the new secret marker', async () => {
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'replacement-secret')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
await state.saveEndpoint(state.localEndpoints[0])
await settle()
expect(api.updateEndpoint).toHaveBeenCalledWith(endpoint.id, {
header_rules: [{ action: 'set', key: 'x-auth', value: 'replacement-secret' }],
})
expect(state.getEndpointEditRules(endpoint.id)[0]).toMatchObject({ value: '***', retainValue: true })
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
})
it('does not overwrite newer edits when a save finishes', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'first-edit')
const saving = state.saveEndpoint(state.localEndpoints[0])
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'newer-edit')
resolveSave(structuredClone(endpoint))
await saving
expect(state.getEndpointEditRules(endpoint.id)[0].value).toBe('newer-edit')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
})
it('refreshes JSON mode with the saved projection instead of retaining submitted plaintext', async () => {
const { state } = await mountDialog()
state.enterEndpointRulesJsonMode(state.localEndpoints[0])
state.updateEndpointRulesJsonDraft(endpoint.id, JSON.stringify({
header_rules: [{ action: 'set', key: 'x-auth', value: 'json-secret' }],
body_rules: [], response_header_rules: [],
}))
await state.saveEndpoint(state.localEndpoints[0])
const savedDraft = state.endpointRulesJsonDraft[endpoint.id]
expect(savedDraft).not.toContain('json-secret')
expect(JSON.parse(savedDraft).header_rules[0]).toMatchObject({ value: '***', has_value: true })
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
})
it('clears secret drafts on close and ignores the stale save response', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { props, state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'unsaved-secret')
const saving = state.saveEndpoint(state.localEndpoints[0])
props.modelValue = false
await settle()
resolveSave(structuredClone(endpoint))
await saving
expect(state.endpointEditStates).toEqual({})
expect(state.localEndpoints).toEqual([])
})
})
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue'
import EndpointRulesRevealDialog from '../EndpointRulesRevealDialog.vue'
import type { ProviderEndpointRules } from '@/api/endpoints'
const api = vi.hoisted(() => ({ revealEndpointRules: vi.fn() }))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Dialog: defineComponent({
props: { modelValue: Boolean },
setup: (props, { slots }) => () => props.modelValue
? h('section', [slots.default?.(), slots.footer?.()]) : null,
}),
Button: defineComponent({ setup: (_props, { slots }) => () => h('button', slots.default?.()) }),
Textarea: defineComponent({
props: { modelValue: String },
setup: (props) => () => h('textarea', { value: props.modelValue }),
}),
}
})
const mounted: Array<{ app: App, root: HTMLElement }> = []
const savedRules: ProviderEndpointRules = {
header_rules: [{ action: 'set', key: 'x-auth', value: 'request-secret' }],
body_rules: [{ action: 'set', path: 'auth.token', value: 'body-secret' }],
response_header_rules: [{ action: 'set', key: 'x-auth', value: 'response-secret' }],
}
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountDialog(open = true) {
const props = reactive({ modelValue: open, endpointId: 'endpoint-1' as string | null })
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(EndpointRulesRevealDialog, {
...props,
'onUpdate:modelValue': (value: boolean) => { props.modelValue = value },
}),
}))
app.mount(root)
mounted.push({ app, root })
return { props, root, app }
}
beforeEach(() => {
api.revealEndpointRules.mockReset().mockResolvedValue(savedRules)
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule reveal', () => {
it('fetches only on demand and displays saved rules read-only', async () => {
const { props, root } = mountDialog(false)
await settle()
expect(api.revealEndpointRules).not.toHaveBeenCalled()
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledOnce()
expect(api.revealEndpointRules.mock.calls[0][0]).toBe('endpoint-1')
const textarea = root.querySelector('textarea')!
expect(JSON.parse(textarea.value)).toEqual(savedRules)
expect(textarea.readOnly).toBe(true)
})
it('clears plaintext on close and fetches again instead of caching', async () => {
const { props, root } = mountDialog()
await settle()
const signal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
root.querySelector('button')!.click()
await settle()
expect(signal.aborted).toBe(true)
expect(root.querySelector('textarea')).toBeNull()
api.revealEndpointRules.mockResolvedValue({ ...savedRules, body_rules: [] })
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledTimes(2)
expect(JSON.parse(root.querySelector('textarea')!.value).body_rules).toEqual([])
})
it('ignores an old endpoint response after switching endpoints', async () => {
let resolveFirst!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const { props, root } = mountDialog()
await settle()
const oldSignal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
props.endpointId = 'endpoint-2'
api.revealEndpointRules.mockResolvedValue({ header_rules: [], body_rules: [], response_header_rules: [] })
await settle()
expect(oldSignal.aborted).toBe(true)
resolveFirst(savedRules)
await settle()
expect(JSON.parse(root.querySelector('textarea')!.value).header_rules).toEqual([])
})
it('ignores an in-flight response after the dialog closes', async () => {
let resolveRequest!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveRequest = resolve }))
const { props, root } = mountDialog()
await settle()
props.modelValue = false
await settle()
resolveRequest(savedRules)
await settle()
expect(root.querySelector('textarea')).toBeNull()
expect(root.innerHTML).not.toContain('secret')
})
it('does not expose API error details in the dialog', async () => {
api.revealEndpointRules.mockRejectedValue(new Error('Bearer upstream-secret'))
const { root } = mountDialog()
await settle()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
expect(root.innerHTML).not.toContain('upstream-secret')
})
})
@@ -132,11 +132,17 @@ describe('ModelMappingDialog', () => {
error: null,
warning: null,
})
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-1',
provider_model_name: 'provider-model-1',
provider_model_mappings: [],
} as Model
}
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -186,12 +192,18 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [],
} as Model
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
@@ -239,7 +251,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -249,7 +267,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [responsesEndpoint.id],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -304,7 +322,13 @@ describe('ModelMappingDialog', () => {
})
it('preserves an edited compact scope when endpoint capabilities are unavailable', async () => {
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -314,7 +338,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: ['endpoint-responses'],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -369,7 +393,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -379,7 +409,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [endpoint.id],
operations: ['Compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -5,29 +5,45 @@ import { renderToString } from '@vue/server-renderer'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
const provider = {
const provider: ProviderWithEndpointsSummary = {
id: 'provider-demo',
name: 'Demo Provider',
provider_type: 'custom',
is_active: true,
active_keys: 0,
api_formats: [],
} as ProviderWithEndpointsSummary
provider_priority: 0,
keep_priority_on_conversion: false,
enable_format_conversion: true,
total_endpoints: 0,
active_endpoints: 0,
total_keys: 0,
total_models: 0,
active_models: 0,
global_model_ids: [],
avg_health_score: null,
unhealthy_endpoints: 0,
endpoint_health_details: [],
ops_configured: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
describe('ModelMappingTab response contracts', () => {
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
const props: InstanceType<typeof ModelMappingTab>['$props'] = {
provider,
models: [],
endpoints: [],
providerKeys: [],
loading: false,
}
Reflect.set(props, 'mappingPreview', {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
})
const app = createSSRApp({
render: () => h(ModelMappingTab, {
provider,
models: [],
endpoints: [],
providerKeys: [],
mappingPreview: {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
},
loading: false,
}),
render: () => h(ModelMappingTab, props),
})
const html = await renderToString(app)
@@ -582,8 +582,8 @@ describe('OAuthAccountDialog authorization and import', () => {
'批量授权完成:成功 0 个(新增 0 个,替换 0 个),失败 4 个;#2 invalid cookie;#3 expired cookie',
'错误',
)
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('third safe reason')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('third safe reason')
expect(toastMocks.warning).not.toHaveBeenCalled()
})
@@ -85,7 +85,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyActionCluster>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -47,7 +47,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyIdentityBlock>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -277,7 +277,7 @@ export function getVisibleCodexResetCreditItems(
if (!snapshot || !Array.isArray(credits)) return []
return credits
.map((item) => {
.map((item): CodexResetCreditDisplayCandidate | null => {
if (!codexResetCreditStatusIsDisplayable(item)) return null
const remainingSeconds = codexResetCreditRemainingSeconds(item, snapshot, nowUnixSecs)
if (remainingSeconds === null || remainingSeconds <= 0) return null
@@ -468,7 +468,8 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
apiFormat = group.apiFormats[0]
} else if (group.apiFormats.length === 0) {
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
apiFormat = group.model.effective_api_format || group.model.api_format
const formats = group.model.effective_config?.api_formats ?? group.model.config?.api_formats ?? []
if (formats.length === 1) apiFormat = formats[0]
}
const result = await testModel(
@@ -456,7 +456,7 @@ function hasRequestPricing(model: Model): boolean {
function hasVideoPricing(model: Model): boolean {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
return !!priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频计费的显示文本
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, type App } from 'vue'
import type { ActionResultResponse } from '@/api/providerOps'
import { useProviderBalance } from '../useProviderBalance'
const api = vi.hoisted(() => ({
batchQueryBalance: vi.fn<() => Promise<Record<string, ActionResultResponse>>>(),
getArchitectures: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/api/providerOps', () => api)
let app: App | undefined
let root: HTMLDivElement
function mountBalance() {
let balance!: ReturnType<typeof useProviderBalance>
root = document.createElement('div')
app = createApp({
setup() {
balance = useProviderBalance()
return () => null
},
})
app.mount(root)
return balance
}
function result(status: ActionResultResponse['status'], available: number): ActionResultResponse {
return {
status,
action_type: 'query_balance',
data: { total_available: available, currency: 'USD', extra: {} },
message: null,
executed_at: '2026-09-07T00:00:00Z',
response_time_ms: 0,
cache_ttl_seconds: 0,
}
}
beforeEach(() => {
vi.useFakeTimers()
api.batchQueryBalance.mockReset()
})
afterEach(() => {
app?.unmount()
app = undefined
root?.remove()
vi.useRealTimers()
})
describe('provider balance refresh', () => {
it('does not overwrite a newer refresh with an older pending retry', async () => {
const balance = mountBalance()
const providers = [{ id: 'provider-1', ops_configured: true }]
let resolveRetry!: (value: Record<string, ActionResultResponse>) => void
api.batchQueryBalance
.mockResolvedValueOnce({ 'provider-1': result('pending', 0) })
.mockImplementationOnce(() => new Promise(resolve => { resolveRetry = resolve }))
.mockResolvedValueOnce({ 'provider-1': result('success', 20) })
await balance.loadBalances(providers)
await vi.advanceTimersByTimeAsync(12_000)
await balance.loadBalances(providers)
resolveRetry({ 'provider-1': result('success', 10) })
await Promise.resolve()
expect(balance.getProviderBalance('provider-1')).toEqual({ available: 20, currency: 'USD' })
})
it('ignores a pending response after unmount', async () => {
const balance = mountBalance()
let resolveLoad!: (value: Record<string, ActionResultResponse>) => void
api.batchQueryBalance.mockImplementationOnce(() => new Promise(resolve => { resolveLoad = resolve }))
const loading = balance.loadBalances([{ id: 'provider-1', ops_configured: true }])
app?.unmount()
app = undefined
resolveLoad({ 'provider-1': result('pending', 10) })
await loading
expect(balance.balanceCache.value).toEqual({})
expect(vi.getTimerCount()).toBe(0)
})
it('preserves zero balances and false check-in results', async () => {
const balance = mountBalance()
api.batchQueryBalance.mockResolvedValueOnce({
'provider-1': {
...result('success', 0),
data: {
total_available: 0,
currency: 'USD',
extra: { balance: 0, points: 0, checkin_success: false, checkin_message: 'try again' },
},
},
})
await balance.loadBalances([{ id: 'provider-1', ops_configured: true }])
expect(balance.getProviderBalanceBreakdown('provider-1')).toEqual({ balance: 0, points: 0, currency: 'USD' })
expect(balance.getProviderCheckin('provider-1')).toEqual({ success: false, message: 'try again' })
})
})
@@ -48,7 +48,7 @@ export function useProviderBalance() {
const schemas: Record<string, CredentialsSchema> = {}
for (const arch of archs) {
if (arch.credentials_schema) {
schemas[arch.architecture_id] = arch.credentials_schema as CredentialsSchema
schemas[arch.architecture_id] = arch.credentials_schema
}
}
architectureSchemas.value = schemas
@@ -59,7 +59,7 @@ export function useProviderBalance() {
}
// 异步加载余额数据(使用批量接口)
async function loadBalances(providers: ProviderWithEndpointsSummary[], fullReload = true) {
async function loadBalances(providers: Pick<ProviderWithEndpointsSummary, 'id' | 'ops_configured'>[], fullReload = true) {
if (fullReload) {
balanceCache.value = {}
}
@@ -108,6 +108,7 @@ export function useProviderBalance() {
async function retryPendingBalances(providerIds: string[], loadVersion: number, retryCount: number) {
try {
const results = await batchQueryBalance(providerIds)
if (loadVersion !== balanceLoadVersion) return
const stillPending: string[] = []
for (const [providerId, result] of Object.entries(results)) {
@@ -173,14 +174,16 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
if (!extra || extra.balance === undefined || extra.points === undefined) {
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || typeof extra.balance !== 'number' || typeof extra.points !== 'number') {
return null
}
return {
balance: extra.balance,
points: extra.points,
currency: data.currency || 'USD',
currency: typeof data.currency === 'string' && data.currency ? data.currency : 'USD',
}
}
@@ -224,13 +227,15 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
if (!extra || extra.checkin_success === undefined) {
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || (extra.checkin_success !== null && typeof extra.checkin_success !== 'boolean')) {
return null
}
return {
success: extra.checkin_success,
message: extra.checkin_message || '',
message: typeof extra.checkin_message === 'string' ? extra.checkin_message : '',
}
}
@@ -244,13 +249,15 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || !extra.cookie_expired) {
return null
}
return {
expired: true,
message: extra.cookie_expired_message || 'Cookie 已失效',
message: typeof extra.cookie_expired_message === 'string' ? extra.cookie_expired_message : 'Cookie 已失效',
}
}
@@ -296,7 +303,9 @@ export function useProviderBalance() {
}
const data = result.data as Record<string, unknown>
const extra = data.extra
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra) return []
// 从 schema 缓存中获取格式化配置
@@ -319,6 +328,7 @@ export function useProviderBalance() {
// 组件卸载时清理
function cleanup() {
balanceLoadVersion++
stopTick()
pendingTimers.forEach(clearTimeout)
pendingTimers.clear()
@@ -36,7 +36,10 @@ function providerModelsFetchResult(response: ProviderModelsQueryResponse): Fetch
if (response.success && response.data?.models) {
const partialWarning = response.data.warning ?? response.data.error
return {
models: response.data.models,
models: response.data.models.map((model) => ({
...model,
api_formats: model.api_formats ?? (model.api_format ? [model.api_format] : []),
})),
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
fromCache: response.data.from_cache,
}
@@ -77,7 +77,7 @@ describe('routingPolicy', () => {
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3 },
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false },
}).default_policy.sticky_key_attempts).toBe(3)
expect(normalizeStickyKeyAttempts('5')).toBe(5)
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
@@ -179,7 +179,7 @@
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<span
v-if="row.kind === 'pool'"
v-if="poolProviderIds.has(row.id)"
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
>
Pool
@@ -42,12 +42,13 @@ describe('usage token normalization', () => {
})
it('does not subtract cache read tokens for Claude usage', () => {
expect(getEffectiveInputTokens({
const usage = {
input_tokens: 4941,
cache_creation_input_tokens: 687,
cache_read_input_tokens: 52873,
output_tokens: 973,
api_format: 'claude:messages',
})).toBe(4941)
}
expect(getEffectiveInputTokens(usage)).toBe(4941)
})
})
@@ -564,7 +564,7 @@ import Badge from '@/components/ui/badge.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import JsonContentPanel from './JsonContentPanel.vue'
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { requestTraceApi, type RequestTrace, type CandidateProxy, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { log } from '@/utils/logger'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
import { parseApiError } from '@/utils/errorParser'
@@ -722,8 +722,8 @@ const formatSize = (bytes: number): string => {
}
// 代理 timing 分阶段展示
const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
const t = proxy.timing as Record<string, number | null | undefined> | undefined
const proxyTimingBreakdown = (proxy: CandidateProxy): string => {
const t = proxy.timing
if (!t) return ''
const parts: string[] = []
@@ -2112,7 +2112,8 @@ const navigateAttempt = (direction: number) => {
// 加载请求追踪数据
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
if (!props.requestId || props.traceData) return
const requestId = props.requestId
if (!requestId || props.traceData) return
if (traceLoadInFlight) return traceLoadInFlight
traceLoadInFlight = (async () => {
@@ -2125,7 +2126,7 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -54,12 +54,13 @@
</template>
<script setup lang="ts">
import type { TimeScatterChartData } from '@/components/charts/types'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
import { meApi } from '@/api/me'
import type { ChartData, ChartOptions } from 'chart.js'
import type { ChartOptions } from 'chart.js'
import { log } from '@/utils/logger'
const props = withDefaults(defineProps<{
@@ -186,7 +187,7 @@ function formatModelName(model: string): string {
}
// 构建图表数据
const chartData = computed<ChartData<'scatter'>>(() => {
const chartData = computed<TimeScatterChartData>(() => {
if (!timelineData.value?.points) {
return { datasets: [] }
}
@@ -728,7 +728,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentHeaderData"
:current-header-data="currentHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderHeaders"
:header-stats="headerStats"
@@ -782,7 +782,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentResponseHeaderData"
:current-header-data="currentResponseHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderResponseHeaders"
:header-stats="responseHeaderStats"
@@ -1412,7 +1412,7 @@ const TIMELINE_MOUNT_DELAY_MS = 120
let loadDetailRequestId = 0
let bodyLoadRequestId = 0
let loadDetailInFlight = false
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
let timelineMountTimer: number | null = null
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
@@ -1990,7 +1990,7 @@ const effectiveCacheCreationCost = computed(() => {
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
].reduce((sum, value) => sum + (value ?? 0), 0)
].reduce<number>((sum, value) => sum + (value ?? 0), 0)
if (snapshotCost > 0) return snapshotCost
return toNumber(detail.value?.cache_creation_cost) ?? 0
})
@@ -115,10 +115,8 @@ interface DisplayLine extends JsonLine {
}
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
type JsonValue = Record<string, unknown> | unknown[] | string | number | boolean | null | undefined
const props = defineProps<{
data: JsonValue
data: unknown
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
@@ -171,8 +171,8 @@ const props = withDefaults(defineProps<{
headerStats: { added: number; modified: number; removed: number; unchanged: number }
isDark: boolean
// 泛化 props:允许传入任意 header 对和标签,用于复用为响应头对比
clientHeaders?: Record<string, unknown>
providerHeaders?: Record<string, unknown>
clientHeaders?: Record<string, unknown> | null
providerHeaders?: Record<string, unknown> | null
clientLabel?: string
providerLabel?: string
emptyMessage?: string
@@ -189,7 +189,7 @@ async function loadUsers(search: string) {
const options = result.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
email: user.email ?? '',
}))
if (!search.trim()) loadedInitialBatch = true
users.value = options
@@ -521,7 +521,7 @@ describe('HorizontalRequestTimeline', () => {
})
await nextTick()
const lastCall = onTraceState.mock.calls.at(-1)?.[0]
const lastCall = onTraceState.mock.calls[onTraceState.mock.calls.length - 1]?.[0]
expect(lastCall).toMatchObject({
finalStatus: 'streaming',
})
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn() }))
vi.mock('@/api/dashboard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail },
}
})
vi.mock('../HorizontalRequestTimeline.vue', () => ({ default: { render: () => null } }))
vi.mock('../JsonContentPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: { type: null, default: null } },
setup(props) {
return () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.data))
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
})
function buildDetail(captured: boolean): RequestDetail {
return {
id: 'usage-full-capture',
request_id: 'req-full-capture',
user: { id: 'user-1', username: 'test-user', email: '[email protected]' },
api_key: { id: 'key-1', name: 'test-key', display: 'test-key' },
provider: 'test-provider',
api_format: 'openai:chat',
model: 'test-model',
tokens: { input: 10, output: 20, total: 30 },
cost: { input: 0, output: 0, total: 0 },
request_type: 'chat',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-09-07T00:00:00Z',
request_headers: { 'content-type': 'application/json', authorization: '[redacted]' },
has_request_body: captured,
has_provider_request_body: false,
has_response_body: captured,
has_client_response_body: false,
}
}
async function openDrawer() {
const isOpen = ref(false)
const Host = defineComponent({
setup: () => () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-full-capture',
}),
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('请求头')
})
}
function findTab(label: string) {
return [...document.body.querySelectorAll('button')]
.find(button => button.textContent?.trim() === label)
}
describe('RequestDetailDrawer body capture', () => {
it('keeps body tabs from shallow availability and loads full captures on demand', async () => {
const shallow = buildDetail(true)
const full: RequestDetail = {
...shallow,
request_body: { messages: [{ role: 'user', content: 'captured request text' }] },
response_body: { choices: [{ message: { role: 'assistant', content: 'captured response text' } }] },
}
apiMocks.getRequestDetail.mockImplementation(async (_requestId, options) => (
options?.includeBodies ? full : shallow
))
await openDrawer()
expect(findTab('请求体')).toBeDefined()
expect(findTab('响应体')).toBeDefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(apiMocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findTab('请求体')!.click()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured request text')
})
expect(apiMocks.getRequestDetail).toHaveBeenLastCalledWith('usage-full-capture', { includeBodies: true })
findTab('响应体')!.click()
await nextTick()
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured response text')
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(2)
})
it('does not offer body tabs or fetch uncaptured bodies for basic records', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildDetail(false))
await openDrawer()
expect(findTab('请求体')).toBeUndefined()
expect(findTab('响应体')).toBeUndefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
})
})
@@ -160,7 +160,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
const statsRaw = statsData
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
@@ -187,7 +187,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
const raw = item
return {
model: item.model,
request_count: item.request_count || 0,
@@ -422,7 +422,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []) as UsageRecord[]
const nextRecords = (response.records || [])
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
const totalKey = buildAdminRecordTotalKey(params)
applyAdminRecordTotal(totalKey, response.total ?? 0, response.total_is_estimated === true)
@@ -524,8 +524,8 @@ export function useUsageData(options: UseUsageDataOptions) {
// 确定是否需要保护 status(避免刷新把已知状态覆盖为 undefined 或回退)
const hasExistingStatus = typeof existing.status === 'string' && existing.status.length > 0
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
const currentRank = hasExistingStatus ? (statusPriority[existing.status ?? ''] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status ?? ''] ?? -1) : -1
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
+1 -69
View File
@@ -1,4 +1,4 @@
import type { ImageProgress } from '@/api/requestTrace'
export type { UsageRecord, RequestStatus } from '@/api/usageRecords'
// 统计数据状态
export interface UsageStatsState {
@@ -77,74 +77,6 @@ export interface ApiFormatStatsItem {
// 请求记录
// 请求状态类型
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
export interface UsageRecord {
id: string
user_id?: string
username?: string
user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
provider_key_name?: string | null
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
input_tokens: number
effective_input_tokens?: number
output_tokens: number
reasoning_tokens?: number
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number
cache_creation_ephemeral_1h_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost: number
actual_cost?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时
end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时
is_stream: boolean
is_websocket?: boolean
websocket_transport?: string | null
usage_available?: boolean
usage_pricing_available?: boolean
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
created_at: string
updated_at?: string | null
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
image_progress?: ImageProgress | null
}
// 日期范围参数
export interface DateRangeParams {
start_date?: string
@@ -9,7 +9,7 @@ import {
describe('service tier facts', () => {
it('uses the final provider request tier for display and billing', () => {
const source = {
const source: Record<string, unknown> = {
service_tier: 'priority',
actual_service_tier: 'default',
settlement: {
@@ -29,7 +29,7 @@ describe('service tier facts', () => {
})
it('does not infer a tier from the provider response or settlement snapshot', () => {
const source = {
const source: Record<string, unknown> = {
actual_service_tier: 'flex',
settlement: {
settlement_snapshot: {
@@ -24,7 +24,7 @@
</div>
<Select
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
@update:model-value="($event === 'skip' || $event === 'wallet' || $event === 'unlimited') && $emit('update:modelValue', $event)"
>
<SelectTrigger class="h-9 w-full">
<SelectValue />
@@ -23,7 +23,7 @@
</div>
<Select
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
@update:model-value="($event === 'user' || $event === 'admin' || $event === 'audit_admin') && $emit('update:modelValue', $event)"
>
<SelectTrigger class="h-10 w-full">
<SelectValue />
@@ -17,7 +17,7 @@
<div class="xl:hidden">
<Select
:model-value="filterRole"
@update:model-value="$emit('update:filterRole', $event)"
@update:model-value="updateRole"
>
<SelectTrigger :class="mobile ? 'w-24 h-8 text-xs border-border/60' : 'w-32 h-8 text-xs border-border/60'">
<SelectValue :placeholder="legacyT(mobile ? '角色' : '全部角色')" />
@@ -37,7 +37,7 @@
<div class="xl:hidden">
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
@update:model-value="updateStatus"
>
<SelectTrigger :class="mobile ? 'w-20 h-8 text-xs border-border/60' : 'w-28 h-8 text-xs border-border/60'">
<SelectValue :placeholder="legacyT(mobile ? '状态' : '全部状态')" />
@@ -78,7 +78,7 @@
<div class="xl:hidden">
<Select
:model-value="sortOption"
@update:model-value="$emit('update:sortOption', $event)"
@update:model-value="updateSort"
>
<SelectTrigger :class="mobile ? 'w-32 h-8 text-xs border-border/60' : 'w-40 h-8 text-xs border-border/60'">
<SelectValue :placeholder="legacyT('排序')" />
@@ -133,7 +133,7 @@ const props = withDefaults(defineProps<{
mobile: false,
})
defineEmits<{
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:filterRole': [value: FilterRole]
'update:filterGroup': [value: string]
@@ -141,6 +141,21 @@ defineEmits<{
'update:sortOption': [value: SortOption]
}>()
function updateRole(value: string) {
const option = props.roleOptions.find((option) => option.value === value)
if (option) emit('update:filterRole', option.value)
}
function updateStatus(value: string) {
const option = props.statusOptions.find((option) => option.value === value)
if (option) emit('update:filterStatus', option.value)
}
function updateSort(value: string) {
const option = props.sortOptions.find((option) => option.value === value)
if (option) emit('update:sortOption', option.value)
}
const { legacyT } = useI18n()
const searchPlaceholder = computed(() => props.mobile ? legacyT('搜索...') : legacyT('搜索用户名或邮箱...'))
</script>
@@ -295,7 +295,7 @@ import {
validatePasswordByPolicy,
type PasswordPolicyLevel,
} from '@/utils/passwordPolicy'
import type { UserGroup } from '@/api/users'
import type { UserGroup, UserRole } from '@/api/users'
export interface UserFormData {
id?: string
@@ -303,7 +303,7 @@ export interface UserFormData {
email: string
initial_gift_usd?: number | null
unlimited?: boolean
role: 'admin' | 'user'
role: UserRole
is_active?: boolean
group_ids?: string[]
feature_settings?: Record<string, unknown> | null
@@ -332,7 +332,7 @@ const form = ref({
confirmPassword: '',
email: '',
initial_gift_usd: 10 as number | undefined,
role: 'user' as 'admin' | 'user',
role: 'user' as UserRole,
unlimited: false,
is_active: true,
group_ids: [] as string[],