mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
+128
@@ -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: '',
|
||||
|
||||
+29
-13
@@ -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)
|
||||
|
||||
+2
-2
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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',
|
||||
})
|
||||
|
||||
+130
@@ -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,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[],
|
||||
|
||||
Reference in New Issue
Block a user