mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +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:
@@ -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
|
||||
}
|
||||
|
||||
// 获取视频计费的显示文本
|
||||
|
||||
Reference in New Issue
Block a user