Merge remote-tracking branch 'upstream/main'

This commit is contained in:
AAEE86
2026-06-16 10:53:47 +08:00
168 changed files with 24971 additions and 1332 deletions
@@ -19,45 +19,95 @@
HTTP 200 但响应体匹配正则时,视为失败并触发转移
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="addRule('success')"
>
<Plus class="w-4 h-4 mr-1" />
添加
</Button>
<div class="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="successJsonMode ? '切回成功转移表单' : '切到成功转移 JSON'"
@click="toggleSuccessJsonMode"
>
<Code2 class="w-3 h-3 mr-1" />
{{ successJsonMode ? '表单' : 'JSON' }}
</Button>
<Button
v-if="successJsonMode"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
title="格式化成功转移 JSON"
@click="formatSuccessJsonDraft"
>
<AlignLeft class="w-3 h-3 mr-1" />
格式化
</Button>
<Button
v-if="!successJsonMode"
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
@click="addRule('success')"
>
<Plus class="w-3 h-3 mr-1" />
添加
</Button>
</div>
</div>
<div
v-if="successPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
v-if="successJsonMode"
class="space-y-2"
>
暂无规则
</div>
<div
v-for="(rule, index) in successPatterns"
:key="'s-' + index"
class="flex items-center gap-1"
>
<Input
v-model="rule.pattern"
placeholder="例如: relay:.*格式错误"
size="sm"
class="font-mono text-xs flex-1"
<Textarea
:model-value="successJsonDraft"
class="min-h-[160px] font-mono text-xs leading-relaxed"
spellcheck="false"
placeholder="[{ &quot;pattern&quot;: &quot;relay:.*格式错误&quot; }]"
@update:model-value="updateSuccessJsonDraft"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('success', index)"
<div
v-if="successJsonError"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{{ successJsonError }}
</div>
<p class="text-xs text-muted-foreground">
仅管理成功转移规则;JSON 应为数组,<code class="bg-muted px-1 rounded">pattern</code> 必填。
</p>
</div>
<template v-else>
<div
v-if="successPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
>
暂无规则
</div>
<div
v-for="(rule, index) in successPatterns"
:key="'s-' + index"
class="flex items-center gap-1"
>
<Input
v-model="rule.pattern"
placeholder="例如: relay:.*格式错误"
size="sm"
class="font-mono text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('success', index)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</template>
</div>
<!-- 错误终止规则 -->
@@ -68,54 +118,104 @@
错误终止规则
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
HTTP 非 200 且响应体匹配正则时,停止转移并直接返回错误。可选填状态码缩小匹配范围
HTTP 非 200 且规则命中时,停止转移并直接返回错误。状态码不填则所有错误状态都尝试匹配正则
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="addRule('error')"
>
<Plus class="w-4 h-4 mr-1" />
添加
</Button>
<div class="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="errorJsonMode ? '切回错误终止表单' : '切到错误终止 JSON'"
@click="toggleErrorJsonMode"
>
<Code2 class="w-3 h-3 mr-1" />
{{ errorJsonMode ? '表单' : 'JSON' }}
</Button>
<Button
v-if="errorJsonMode"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
title="格式化错误终止 JSON"
@click="formatErrorJsonDraft"
>
<AlignLeft class="w-3 h-3 mr-1" />
格式化
</Button>
<Button
v-if="!errorJsonMode"
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
@click="addRule('error')"
>
<Plus class="w-3 h-3 mr-1" />
添加
</Button>
</div>
</div>
<div
v-if="errorPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
v-if="errorJsonMode"
class="space-y-2"
>
暂无规则
<Textarea
:model-value="errorJsonDraft"
class="min-h-[180px] font-mono text-xs leading-relaxed"
spellcheck="false"
placeholder="[{ &quot;pattern&quot;: &quot;content_policy_violation&quot; }, { &quot;status_codes&quot;: [429, 500, 503], &quot;pattern&quot;: &quot;&quot; }]"
@update:model-value="updateErrorJsonDraft"
/>
<div
v-if="errorJsonError"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{{ errorJsonError }}
</div>
<p class="text-xs text-muted-foreground">
仅管理错误终止规则;JSON 应为数组,<code class="bg-muted px-1 rounded">status_codes</code> 选填数组,不填则所有错误状态都尝试匹配 <code class="bg-muted px-1 rounded">pattern</code>;两者至少填一个。
</p>
</div>
<div
v-for="(rule, index) in errorPatterns"
:key="'e-' + index"
class="flex items-center gap-1"
>
<Input
v-model="statusCodeInputs[index]"
placeholder="状态码 (可选)"
size="sm"
class="font-mono text-xs w-28 shrink-0"
/>
<Input
v-model="rule.pattern"
placeholder="例如: content_policy_violation"
size="sm"
class="font-mono text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('error', index)"
<template v-else>
<div
v-if="errorPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
暂无规则
</div>
<div
v-for="(rule, index) in errorPatterns"
:key="'e-' + index"
class="flex items-center gap-1"
>
<Input
v-model="statusCodeInputs[index]"
placeholder="状态码 (选填,可多个)"
size="sm"
class="font-mono text-xs w-40 shrink-0"
/>
<Input
v-model="rule.pattern"
placeholder="正则内容 (选填)"
size="sm"
class="font-mono text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('error', index)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</template>
</div>
</div>
@@ -143,12 +243,13 @@ import {
Dialog,
Button,
Input,
Textarea,
} from '@/components/ui'
import { GitBranch, Plus, Trash2 } from 'lucide-vue-next'
import { AlignLeft, Code2, GitBranch, Plus, Trash2 } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
import type { FailoverRuleItem } from '@/api/endpoints/types'
import type { FailoverRuleItem, FailoverRulesConfig } from '@/api/endpoints/types'
const props = defineProps<{
open: boolean
@@ -166,15 +267,105 @@ const saving = ref(false)
const successPatterns = ref<FailoverRuleItem[]>([])
const errorPatterns = ref<FailoverRuleItem[]>([])
const statusCodeInputs = ref<string[]>([])
const successJsonMode = ref(false)
const successJsonDraft = ref('')
const successJsonError = ref<string | null>(null)
const successJsonDirty = ref(false)
const errorJsonMode = ref(false)
const errorJsonDraft = ref('')
const errorJsonError = ref<string | null>(null)
const errorJsonDirty = ref(false)
const TOP_LEVEL_STOP_STATUS_CODE_KEYS = [
'stop_status_codes',
'stop_on_status_codes',
'early_stop_status_codes',
'non_retryable_status_codes',
] as const
const MANAGED_FAILOVER_RULE_KEYS = [
'success_failover_patterns',
'error_stop_patterns',
...TOP_LEVEL_STOP_STATUS_CODE_KEYS,
] as const
function isJsonObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function uniqueStatusCodes(codes: number[]): number[] {
return codes.filter((code, index, values) =>
Number.isInteger(code) && code >= 100 && code <= 599 && values.indexOf(code) === index
)
}
function collectTopLevelStopStatusCodes(rules: ProviderWithEndpointsSummary['failover_rules']): number[] {
if (!rules) return []
return uniqueStatusCodes(TOP_LEVEL_STOP_STATUS_CODE_KEYS.flatMap(key => rules[key] || []))
}
function hasPersistableFailoverValue(value: unknown): boolean {
if (value === null || value === undefined) return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'string') return value.trim().length > 0
if (isJsonObject(value)) return Object.values(value).some(hasPersistableFailoverValue)
return true
}
function buildPreservedFailoverRules(
rules: ProviderWithEndpointsSummary['failover_rules'],
): Record<string, unknown> {
if (!isJsonObject(rules)) return {}
const preserved: Record<string, unknown> = { ...rules }
for (const key of MANAGED_FAILOVER_RULE_KEYS) {
delete preserved[key]
}
return preserved
}
function buildNextFailoverRules(
filteredSuccess: FailoverRuleItem[],
filteredError: FailoverRuleItem[],
): FailoverRulesConfig | null {
const nextRules = buildPreservedFailoverRules(props.provider?.failover_rules)
if (filteredSuccess.length > 0) {
nextRules.success_failover_patterns = filteredSuccess
}
if (filteredError.length > 0) {
nextRules.error_stop_patterns = filteredError
}
return Object.values(nextRules).some(hasPersistableFailoverValue)
? nextRules as FailoverRulesConfig
: null
}
watch(() => [props.open, props.provider], () => {
if (props.open && props.provider) {
const rules = props.provider.failover_rules
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({ ...r }))
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({ ...r }))
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({
...r,
pattern: r.pattern || '',
}))
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({
...r,
pattern: r.pattern || '',
}))
const topLevelStopStatusCodes = collectTopLevelStopStatusCodes(rules)
if (topLevelStopStatusCodes.length > 0) {
errorPatterns.value.push({
pattern: '',
description: '',
status_codes: topLevelStopStatusCodes,
})
}
statusCodeInputs.value = errorPatterns.value.map(r =>
r.status_codes?.length ? r.status_codes.join(',') : ''
)
successJsonMode.value = false
errorJsonMode.value = false
refreshSuccessJsonDraft()
refreshErrorJsonDraft()
}
}, { immediate: true })
@@ -201,9 +392,16 @@ function handleClose() {
emit('update:open', false)
}
function parseStatusCodes(input: string): { valid: true; codes?: number[] } | { valid: false; reason: string } {
function parseStatusCodes(
input: string,
required = false,
): { valid: true; codes: number[] } | { valid: false; reason: string } {
const trimmed = input.trim()
if (!trimmed) return { valid: true }
if (!trimmed) {
return required
? { valid: false, reason: '状态码不能为空' }
: { valid: true, codes: [] }
}
const parts = trimmed.split(/[,\s]+/)
const codes: number[] = []
for (const part of parts) {
@@ -213,11 +411,21 @@ function parseStatusCodes(input: string): { valid: true; codes?: number[] } | {
if (n < 100 || n > 599) return { valid: false, reason: `${n} 不在 100-599 范围内` }
codes.push(n)
}
return { valid: true, codes: codes.length > 0 ? codes : undefined }
const uniqueCodes = Array.from(new Set(codes))
return uniqueCodes.length > 0
? { valid: true, codes: uniqueCodes }
: required
? { valid: false, reason: '状态码不能为空' }
: { valid: true, codes: [] }
}
function validatePattern(pattern: string): string | null {
if (!pattern.trim()) return '正则表达式不能为空'
return validateOptionalPattern(pattern)
}
function validateOptionalPattern(pattern: string): string | null {
if (!pattern.trim()) return null
try {
new RegExp(pattern)
return null
@@ -226,12 +434,301 @@ function validatePattern(pattern: string): string | null {
}
}
function buildSuccessJsonRulesFromForm(): FailoverRuleItem[] {
return successPatterns.value
.map(rule => ({
...rule,
pattern: rule.pattern.trim(),
status_codes: rule.status_codes?.length ? uniqueStatusCodes(rule.status_codes) : undefined,
}))
.filter(rule => rule.pattern)
}
function buildErrorJsonRulesFromForm(): FailoverRuleItem[] {
return errorPatterns.value
.map((rule, index) => {
const parsed = parseStatusCodes(statusCodeInputs.value[index] || '')
const statusCodes = parsed.valid
? parsed.codes
: uniqueStatusCodes(rule.status_codes || [])
return {
...rule,
pattern: rule.pattern.trim(),
status_codes: statusCodes.length > 0 ? statusCodes : undefined,
}
})
.filter(rule => rule.pattern || (rule.status_codes?.length || 0) > 0)
}
function stringifyRuleArray(rules: FailoverRuleItem[]): string {
return JSON.stringify(rules, null, 2)
}
function refreshSuccessJsonDraft() {
successJsonDraft.value = stringifyRuleArray(buildSuccessJsonRulesFromForm())
successJsonError.value = null
successJsonDirty.value = false
}
function refreshErrorJsonDraft() {
errorJsonDraft.value = stringifyRuleArray(buildErrorJsonRulesFromForm())
errorJsonError.value = null
errorJsonDirty.value = false
}
function updateSuccessJsonDraft(value: string) {
successJsonDraft.value = value
successJsonDirty.value = true
successJsonError.value = null
}
function updateErrorJsonDraft(value: string) {
errorJsonDraft.value = value
errorJsonDirty.value = true
errorJsonError.value = null
}
function readJsonRuleArray(
parsed: unknown,
key: 'success_failover_patterns' | 'error_stop_patterns',
): { root: Record<string, unknown>; value: unknown[]; error: string | null } {
if (Array.isArray(parsed)) return { root: {}, value: parsed, error: null }
if (!isJsonObject(parsed)) return { root: {}, value: [], error: '规则 JSON 必须是数组或对象' }
const root = isJsonObject(parsed.failover_rules) ? parsed.failover_rules : parsed
const raw = root[key]
if (raw === undefined || raw === null) return { root, value: [], error: null }
if (!Array.isArray(raw)) return { root, value: [], error: `${key} 必须是数组或 null` }
return { root, value: raw, error: null }
}
function parseJsonStatusCodes(
value: unknown,
label: string,
required: boolean,
): { codes: number[]; error: string | null } {
if (value === undefined || value === null) {
return required
? { codes: [], error: `${label}status_codes 必填` }
: { codes: [], error: null }
}
if (!Array.isArray(value)) return { codes: [], error: `${label}status_codes 必须是数组` }
const codes: number[] = []
for (const item of value) {
if (!Number.isInteger(item)) {
return { codes: [], error: `${label}status_codes 只能包含整数` }
}
const code = item as number
if (code < 100 || code > 599) {
return { codes: [], error: `${label}status_codes 只能填写 100-599` }
}
codes.push(code)
}
const uniqueCodes = uniqueStatusCodes(codes)
if (required && uniqueCodes.length === 0) {
return { codes: [], error: `${label}status_codes 必填` }
}
return { codes: uniqueCodes, error: null }
}
function parseJsonSuccessRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
const label = `成功转移 JSON 第 ${index + 1} 条:`
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
if (typeof rule.pattern !== 'string' || !rule.pattern.trim()) {
return { rule: null, error: `${label}pattern 必填` }
}
const pattern = rule.pattern.trim()
const patternError = validatePattern(pattern)
if (patternError) return { rule: null, error: `${label}${patternError}` }
const status = parseJsonStatusCodes(rule.status_codes, label, false)
if (status.error) return { rule: null, error: status.error }
return {
rule: {
pattern,
description: typeof rule.description === 'string' ? rule.description : undefined,
status_codes: status.codes.length > 0 ? status.codes : undefined,
},
error: null,
}
}
function parseJsonErrorRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
const label = `错误终止 JSON 第 ${index + 1} 条:`
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
const status = parseJsonStatusCodes(rule.status_codes, label, false)
if (status.error) return { rule: null, error: status.error }
if (rule.pattern !== undefined && rule.pattern !== null && typeof rule.pattern !== 'string') {
return { rule: null, error: `${label}pattern 必须是字符串` }
}
const pattern = typeof rule.pattern === 'string' ? rule.pattern.trim() : ''
const patternError = validateOptionalPattern(pattern)
if (patternError) return { rule: null, error: `${label}${patternError}` }
if (status.codes.length === 0 && !pattern) {
return { rule: null, error: `${label}status_codes 和 pattern 至少填写一个` }
}
return {
rule: {
pattern,
description: typeof rule.description === 'string' ? rule.description : undefined,
status_codes: status.codes.length > 0 ? status.codes : undefined,
},
error: null,
}
}
function parseSuccessRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
const raw = draft.trim()
if (!raw) return { value: [], error: null }
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error: unknown) {
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
}
const rules = readJsonRuleArray(parsed, 'success_failover_patterns')
if (rules.error) return { value: null, error: rules.error }
const normalized: FailoverRuleItem[] = []
for (let i = 0; i < rules.value.length; i++) {
const parsedRule = parseJsonSuccessRule(rules.value[i], i)
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
normalized.push(parsedRule.rule)
}
return { value: normalized, error: null }
}
function parseErrorRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
const raw = draft.trim()
if (!raw) return { value: [], error: null }
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error: unknown) {
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
}
const rules = readJsonRuleArray(parsed, 'error_stop_patterns')
if (rules.error) return { value: null, error: rules.error }
const normalized: FailoverRuleItem[] = []
for (let i = 0; i < rules.value.length; i++) {
const parsedRule = parseJsonErrorRule(rules.value[i], i)
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
normalized.push(parsedRule.rule)
}
for (const key of TOP_LEVEL_STOP_STATUS_CODE_KEYS) {
const status = parseJsonStatusCodes(rules.root[key], `${key}:`, false)
if (status.error) return { value: null, error: status.error }
if (status.codes.length > 0) {
normalized.push({
pattern: '',
description: '',
status_codes: status.codes,
})
}
}
return { value: normalized, error: null }
}
function applySuccessJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
const notifyError = options.notifyError !== false
const parsed = parseSuccessRulesJsonDraft(successJsonDraft.value)
if (!parsed.value) {
successJsonError.value = parsed.error
if (notifyError) showError(parsed.error || '成功转移规则 JSON 无效', '验证失败')
return false
}
successPatterns.value = parsed.value.map(rule => ({ ...rule }))
successJsonDraft.value = stringifyRuleArray(parsed.value)
successJsonError.value = null
successJsonDirty.value = false
if (options.notify !== false) success('成功转移 JSON 已应用')
return true
}
function applyErrorJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
const notifyError = options.notifyError !== false
const parsed = parseErrorRulesJsonDraft(errorJsonDraft.value)
if (!parsed.value) {
errorJsonError.value = parsed.error
if (notifyError) showError(parsed.error || '错误终止规则 JSON 无效', '验证失败')
return false
}
errorPatterns.value = parsed.value.map(rule => ({ ...rule }))
statusCodeInputs.value = errorPatterns.value.map(rule => rule.status_codes?.join(',') || '')
errorJsonDraft.value = stringifyRuleArray(parsed.value)
errorJsonError.value = null
errorJsonDirty.value = false
if (options.notify !== false) success('错误终止 JSON 已应用')
return true
}
function toggleSuccessJsonMode() {
if (successJsonMode.value) {
if (successJsonDirty.value && !applySuccessJsonDraft({ notify: false })) return
successJsonMode.value = false
return
}
refreshSuccessJsonDraft()
successJsonMode.value = true
}
function toggleErrorJsonMode() {
if (errorJsonMode.value) {
if (errorJsonDirty.value && !applyErrorJsonDraft({ notify: false })) return
errorJsonMode.value = false
return
}
refreshErrorJsonDraft()
errorJsonMode.value = true
}
function formatSuccessJsonDraft() {
const currentDraft = successJsonDraft.value
const parsed = parseSuccessRulesJsonDraft(currentDraft)
if (!parsed.value) {
successJsonError.value = parsed.error
return
}
const formattedDraft = stringifyRuleArray(parsed.value)
successJsonDraft.value = formattedDraft
successJsonError.value = null
if (formattedDraft !== currentDraft) {
successJsonDirty.value = true
}
}
function formatErrorJsonDraft() {
const currentDraft = errorJsonDraft.value
const parsed = parseErrorRulesJsonDraft(currentDraft)
if (!parsed.value) {
errorJsonError.value = parsed.error
return
}
const formattedDraft = stringifyRuleArray(parsed.value)
errorJsonDraft.value = formattedDraft
errorJsonError.value = null
if (formattedDraft !== currentDraft) {
errorJsonDirty.value = true
}
}
async function handleSave() {
if (!props.provider) return
if (successJsonMode.value && !applySuccessJsonDraft({ notify: false })) return
if (errorJsonMode.value && !applyErrorJsonDraft({ notify: false })) return
// Validate patterns
const allPatterns = [...successPatterns.value, ...errorPatterns.value]
for (const rule of allPatterns) {
for (const rule of successPatterns.value) {
const err = validatePattern(rule.pattern)
if (err) {
showError(err, '验证失败')
@@ -247,23 +744,39 @@ async function handleSave() {
showError(`状态码格式错误: ${result.reason},请输入 100-599 之间的整数,多个用逗号分隔`, '验证失败')
return
}
errorPatterns.value[i].status_codes = result.codes
const patternErr = validateOptionalPattern(errorPatterns.value[i].pattern)
if (patternErr) {
showError(patternErr, '验证失败')
return
}
const pattern = errorPatterns.value[i].pattern.trim()
if (result.codes.length === 0 && !pattern) {
showError(`第 ${i + 1} 条错误终止规则:状态码和正则内容至少填写一个`, '验证失败')
return
}
errorPatterns.value[i].status_codes = result.codes.length > 0 ? result.codes : undefined
}
saving.value = true
try {
const filteredSuccess = successPatterns.value.filter(r => r.pattern.trim())
const filteredError = errorPatterns.value.filter(r => r.pattern.trim())
const filteredSuccess = successPatterns.value
.map(r => ({ ...r, pattern: r.pattern.trim() }))
.filter(r => r.pattern)
const filteredError = errorPatterns.value
.map(r => {
const statusCodes = r.status_codes?.length ? r.status_codes : undefined
return {
...r,
pattern: r.pattern.trim(),
status_codes: statusCodes,
}
})
.filter(r => r.pattern || (r.status_codes?.length || 0) > 0)
const hasRules = filteredSuccess.length > 0 || filteredError.length > 0
const nextFailoverRules = buildNextFailoverRules(filteredSuccess, filteredError)
await updateProvider(props.provider.id, {
failover_rules: hasRules
? {
success_failover_patterns: filteredSuccess,
error_stop_patterns: filteredError,
}
: null,
failover_rules: nextFailoverRules,
})
success('故障转移规则已保存')
@@ -0,0 +1,435 @@
<template>
<Dialog
:model-value="modelValue"
title="提供商批量处理"
:description="dialogDescription"
:icon="Users"
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
>
<div class="space-y-3.5">
<!-- 步骤 1:选择动作(顶部分段控件) -->
<div>
<div class="grid grid-cols-3 gap-1.5 rounded-xl border bg-muted/30 p-1.5">
<button
v-for="action in actionOptions"
:key="action.value"
type="button"
class="group flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-60"
:class="getSegmentClass(action)"
:disabled="executing"
@click="selectedAction = action.value"
>
<component
:is="action.icon"
class="h-4 w-4 shrink-0"
/>
<span>{{ action.label }}</span>
</button>
</div>
<p
class="mt-2 flex items-center gap-1.5 px-1 text-xs leading-relaxed"
:class="selectedActionMeta?.destructive ? 'text-destructive' : 'text-muted-foreground'"
>
<AlertTriangle
v-if="selectedActionMeta?.destructive"
class="h-3.5 w-3.5 shrink-0"
/>
<span>{{ selectedActionMeta?.hint }}</span>
</p>
</div>
<!-- 步骤 2:筛选 + 选择 -->
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchText"
class="h-9 w-full pl-9"
placeholder="搜索提供商名称 / 类型 / 备注"
:disabled="executing"
/>
</div>
<label class="flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-background px-3 text-xs text-muted-foreground transition-colors hover:bg-muted/40">
<Checkbox
:checked="allFilteredSelected"
:disabled="filteredProviders.length === 0 || executing"
@update:checked="toggleFilteredSelection"
/>
<span>全选{{ searchText.trim() ? '筛选结果' : '本页' }}</span>
</label>
<Button
variant="ghost"
size="sm"
class="h-9 px-3 text-xs"
:disabled="selectedCount === 0 || executing"
@click="clearSelection"
>
清空选择
</Button>
</div>
<!-- 提供商列表(占满整宽) -->
<div class="flex min-w-0 flex-col overflow-hidden rounded-lg border">
<div class="grid grid-cols-[1.75rem_minmax(0,1fr)_8.5rem] items-center gap-2 border-b bg-muted/30 px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<Checkbox
:checked="allFilteredSelected"
:disabled="filteredProviders.length === 0 || executing"
@update:checked="toggleFilteredSelection"
/>
<span class="normal-case tracking-normal">提供商</span>
<span class="text-right normal-case tracking-normal">资源(活跃 / 总数)</span>
</div>
<div class="max-h-[min(52vh,440px)] overflow-y-auto">
<div
v-if="filteredProviders.length === 0"
class="flex flex-col items-center justify-center gap-1.5 py-14 text-center"
>
<Search class="h-6 w-6 text-muted-foreground/50" />
<span class="text-sm text-muted-foreground">无匹配提供商</span>
</div>
<label
v-for="provider in filteredProviders"
:key="provider.id"
class="grid cursor-pointer grid-cols-[1.75rem_minmax(0,1fr)_8.5rem] items-center gap-2 border-b px-3 py-2.5 transition-colors last:border-b-0"
:class="rowClass(provider.id)"
>
<Checkbox
:checked="selectedIdSet.has(provider.id)"
:disabled="executing"
@update:checked="(checked) => toggleProvider(provider.id, checked)"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-sm font-medium">{{ provider.name }}</span>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="shrink-0 text-[10px]"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span class="shrink-0 rounded bg-muted px-1.5 py-px font-mono text-[10px]">{{ provider.provider_type || 'custom' }}</span>
<span
v-if="provider.description"
class="truncate"
>{{ provider.description }}</span>
</div>
</div>
<div class="flex flex-col items-end gap-0.5 text-[11px] leading-tight text-muted-foreground">
<span><span class="text-muted-foreground/70">端点</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_endpoints }}/{{ provider.total_endpoints }}</span></span>
<span><span class="text-muted-foreground/70">账号</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_keys }}/{{ provider.total_keys }}</span></span>
<span><span class="text-muted-foreground/70">模型</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_models }}/{{ provider.total_models }}</span></span>
</div>
</label>
</div>
</div>
<!-- 执行进度 / 结果 -->
<div
v-if="executing"
class="space-y-1.5 rounded-lg border bg-muted/15 px-3 py-2.5"
>
<div class="flex items-center justify-between text-xs">
<span class="truncate text-foreground">{{ progressLabel }}</span>
<span class="shrink-0 font-medium tabular-nums text-muted-foreground">{{ progressDone }} / {{ progressTotal }}</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all duration-150"
:class="selectedActionMeta?.destructive ? 'bg-destructive' : 'bg-primary'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
</div>
<div
v-else-if="lastResultMessage"
class="rounded-lg border bg-background px-3 py-2.5 text-xs text-muted-foreground"
>
{{ lastResultMessage }}
</div>
</div>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>已选 <span class="font-semibold tabular-nums text-foreground">{{ selectedCount }}</span></span>
<span class="text-border">·</span>
<span>本页 <span class="tabular-nums">{{ providers.length }}</span></span>
<span class="text-emerald-600 dark:text-emerald-400">活跃 <span class="tabular-nums">{{ activeProviderCount }}</span></span>
<span>停用 <span class="tabular-nums">{{ inactiveProviderCount }}</span></span>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
:disabled="executing"
@click="emit('update:modelValue', false)"
>
关闭
</Button>
<Button
:variant="selectedAction === 'delete' ? 'destructive' : 'default'"
:disabled="!canExecute"
@click="confirmAndExecute"
>
{{ executeButtonLabel }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, type Component } from 'vue'
import { AlertTriangle, Power, PowerOff, Search, Trash2, Users } from 'lucide-vue-next'
import { Badge, Button, Checkbox, Dialog, Input } from '@/components/ui'
import {
deleteProvider,
getProviderDeleteTask,
updateProvider,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
type ProviderBatchAction = 'enable' | 'disable' | 'delete'
interface ProviderBatchActionOption {
value: ProviderBatchAction
label: string
hint: string
icon: Component
destructive?: boolean
}
const props = defineProps<{
modelValue: boolean
providers: ProviderWithEndpointsSummary[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
changed: []
}>()
const { confirm } = useConfirm()
const { success, warning, error: showError } = useToast()
const actionOptions: ProviderBatchActionOption[] = [
{ value: 'enable', label: '启用', hint: '恢复所选提供商参与调度。', icon: Power },
{ value: 'disable', label: '停用', hint: '停止所选提供商参与调度,保留配置。', icon: PowerOff },
{ value: 'delete', label: '删除', hint: '永久删除所选提供商及其端点、账号和配置,此操作不可恢复。', icon: Trash2, destructive: true },
]
const SEGMENT_ACCENT: Record<ProviderBatchAction, string> = {
enable: 'bg-background text-emerald-600 shadow-sm ring-1 ring-emerald-500/25 dark:text-emerald-400',
disable: 'bg-background text-primary shadow-sm ring-1 ring-primary/25',
delete: 'bg-background text-destructive shadow-sm ring-1 ring-destructive/30',
}
const searchText = ref('')
const selectedProviderIds = ref<string[]>([])
const selectedAction = ref<ProviderBatchAction>('disable')
const executing = ref(false)
const progressDone = ref(0)
const progressTotal = ref(0)
const progressLabel = ref('')
const lastResultMessage = ref('')
const dialogDescription = computed(() => '批量启用、停用或删除当前页提供商')
const selectedIdSet = computed(() => new Set(selectedProviderIds.value))
const selectedCount = computed(() => selectedProviderIds.value.length)
const selectedActionLabel = computed(() => actionOptions.find(action => action.value === selectedAction.value)?.label || '')
const selectedActionMeta = computed(() => actionOptions.find(action => action.value === selectedAction.value))
const executeButtonLabel = computed(() => {
if (executing.value) return '执行中...'
if (selectedCount.value > 0) return `${selectedActionLabel.value} ${selectedCount.value} 项`
return `执行${selectedActionLabel.value}`
})
const activeProviderCount = computed(() => props.providers.filter(provider => provider.is_active).length)
const inactiveProviderCount = computed(() => props.providers.length - activeProviderCount.value)
const providerById = computed(() => new Map(props.providers.map(provider => [provider.id, provider])))
const filteredProviders = computed(() => {
const keyword = searchText.value.trim().toLowerCase()
if (!keyword) return props.providers
return props.providers.filter((provider) => {
return [
provider.name,
provider.provider_type,
provider.description,
provider.website,
].some(value => String(value || '').toLowerCase().includes(keyword))
})
})
const allFilteredSelected = computed(() => {
return filteredProviders.value.length > 0
&& filteredProviders.value.every(provider => selectedIdSet.value.has(provider.id))
})
const progressPercent = computed(() => {
if (progressTotal.value <= 0) return 0
return Math.min(100, Math.round((progressDone.value / progressTotal.value) * 100))
})
const canExecute = computed(() => selectedCount.value > 0 && !executing.value)
function toggleProvider(providerId: string, checked: boolean): void {
const next = new Set(selectedProviderIds.value)
if (checked) next.add(providerId)
else next.delete(providerId)
selectedProviderIds.value = [...next]
}
function toggleFilteredSelection(checked: boolean): void {
const next = new Set(selectedProviderIds.value)
for (const provider of filteredProviders.value) {
if (checked) next.add(provider.id)
else next.delete(provider.id)
}
selectedProviderIds.value = [...next]
}
function clearSelection(): void {
selectedProviderIds.value = []
}
function handleDialogUpdate(open: boolean): void {
if (executing.value && !open) return
emit('update:modelValue', open)
}
function getSegmentClass(action: ProviderBatchActionOption): string {
if (selectedAction.value !== action.value) {
return 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
}
return SEGMENT_ACCENT[action.value]
}
function rowClass(providerId: string): string {
if (!selectedIdSet.value.has(providerId)) {
return 'hover:bg-muted/40'
}
return selectedActionMeta.value?.destructive
? 'bg-destructive/5 hover:bg-destructive/10'
: 'bg-primary/5 hover:bg-primary/10'
}
async function confirmAndExecute(): Promise<void> {
if (!canExecute.value) return
const action = actionOptions.find(item => item.value === selectedAction.value)
const actionLabel = action?.label || '批量操作'
const confirmed = await confirm({
title: `批量${actionLabel}提供商`,
message: selectedAction.value === 'delete'
? `将删除 ${selectedCount.value} 个提供商,并同时删除其所有端点、账号和配置。此操作不可恢复,是否继续?`
: `将对 ${selectedCount.value} 个提供商执行:${actionLabel},是否继续?`,
confirmText: selectedAction.value === 'delete' ? '确认删除' : '确认执行',
...(selectedAction.value === 'delete' ? { variant: 'destructive' as const } : {}),
})
if (!confirmed) return
await executeBatchAction()
}
const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3
async function pollProviderDeleteTask(providerId: string, taskId: string): Promise<void> {
const deadline = Date.now() + DELETE_POLL_MAX_MS
let consecutiveFailures = 0
while (Date.now() < deadline) {
try {
const task = await getProviderDeleteTask(providerId, taskId)
consecutiveFailures = 0
if (task.status === 'completed') return
if (task.status === 'failed') {
throw new Error(task.message || 'provider delete task failed')
}
} catch (err) {
consecutiveFailures += 1
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
throw err
}
}
await new Promise(resolve => setTimeout(resolve, DELETE_POLL_INTERVAL_MS))
}
throw new Error('provider delete task timeout')
}
async function executeBatchAction(): Promise<void> {
if (executing.value) return
const targets = selectedProviderIds.value
.map(id => providerById.value.get(id))
.filter((provider): provider is ProviderWithEndpointsSummary => Boolean(provider))
if (targets.length === 0) {
warning('请先选择提供商')
return
}
executing.value = true
progressDone.value = 0
progressTotal.value = targets.length
lastResultMessage.value = ''
let successCount = 0
let failedCount = 0
try {
for (const provider of targets) {
progressLabel.value = `正在${selectedActionLabel.value}:${provider.name}`
try {
if (selectedAction.value === 'delete') {
const result = await deleteProvider(provider.id)
await pollProviderDeleteTask(provider.id, result.task_id)
} else {
await updateProvider(provider.id, { is_active: selectedAction.value === 'enable' })
}
successCount += 1
} catch (err) {
failedCount += 1
// eslint-disable-next-line no-console
console.error(`[ProviderBatchActionDialog] ${selectedAction.value} failed (${provider.id}):`, err)
} finally {
progressDone.value += 1
}
}
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount}`
if (failedCount > 0) warning(lastResultMessage.value)
else success(lastResultMessage.value)
if (successCount > 0) {
clearSelection()
emit('changed')
}
} catch (err) {
showError(parseApiError(err, '批量处理提供商失败'), '错误')
} finally {
executing.value = false
progressDone.value = 0
progressTotal.value = 0
progressLabel.value = ''
}
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
searchText.value = ''
selectedProviderIds.value = []
selectedAction.value = 'disable'
lastResultMessage.value = ''
},
)
watch(
() => props.providers.map(provider => provider.id),
() => {
const availableIds = new Set(props.providers.map(provider => provider.id))
selectedProviderIds.value = selectedProviderIds.value.filter(id => availableIds.has(id))
},
)
</script>
@@ -1627,11 +1627,23 @@ const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
// 故障转移规则
const failoverRulesDialogOpen = ref(false)
const FAILOVER_RULE_ARRAY_KEYS = [
'success_failover_patterns',
'error_stop_patterns',
'stop_status_codes',
'stop_on_status_codes',
'early_stop_status_codes',
'non_retryable_status_codes',
'continue_on_status_codes',
'retryable_status_codes',
'retry_on_status_codes',
'continue_status_codes',
] as const
const hasFailoverRules = computed(() => {
const rules = provider.value?.failover_rules
if (!rules) return false
return (rules.success_failover_patterns?.length || 0) > 0
|| (rules.error_stop_patterns?.length || 0) > 0
return FAILOVER_RULE_ARRAY_KEYS.some(key => (rules[key]?.length || 0) > 0)
|| typeof rules.max_retries === 'number'
})
// Provider 级别代理配置状态
@@ -112,6 +112,16 @@
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="批量处理提供商"
:disabled="loading"
@click="$emit('batchProcess')"
>
<Users class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -131,7 +141,7 @@
</template>
<script setup lang="ts">
import { Search, Plus, ChevronDown, FilterX } from 'lucide-vue-next'
import { Search, Plus, ChevronDown, FilterX, Users } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Select from '@/components/ui/select.vue'
@@ -162,6 +172,7 @@ defineEmits<{
'update:filterModel': [value: string]
'resetFilters': []
'openPriorityDialog': []
'batchProcess': []
'addProvider': []
'refresh': []
}>()
@@ -1,4 +1,5 @@
export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
export { default as ProviderBatchActionDialog } from './ProviderBatchActionDialog.vue'
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
export { default as KeyFormDialog } from './KeyFormDialog.vue'
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'