Files
Aether/frontend/src/features/providers/components/EndpointFormDialog.vue
T

3465 lines
140 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Dialog
:model-value="internalOpen"
title="端点管理"
:description="`管理 ${provider?.name} 的 API 端点`"
:icon="Settings"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<div class="flex flex-col gap-4">
<!-- 已有端点列表(可滚动) -->
<div
v-if="localEndpoints.length > 0"
class="space-y-3 max-h-[50vh] overflow-y-auto scrollbar-hide"
>
<Label class="text-muted-foreground">已配置的端点</Label>
<!-- 端点卡片列表 -->
<div class="space-y-3">
<div
v-for="endpoint in localEndpoints"
:key="endpoint.id"
class="rounded-lg border bg-card"
:class="{ 'opacity-60': !endpoint.is_active }"
>
<!-- 卡片头部:格式名称 + 状态 + 操作 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<div class="flex items-center gap-3">
<span class="font-medium">{{ formatApiFormat(endpoint.api_format) }}</span>
<Badge
v-if="!endpoint.is_active"
variant="secondary"
class="text-xs"
>
停用
</Badge>
</div>
<div
v-if="!isEndpointConfigReadOnly"
class="flex items-center gap-1.5"
>
<!-- 格式转换按钮 -->
<span
class="mr-1"
:title="isEndpointFormatConversionDisabled ? formatConversionDisabledTooltip : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
>
<Button
variant="ghost"
size="icon"
:class="`h-7 w-7 ${endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''} ${isEndpointFormatConversionDisabled ? 'opacity-50' : ''}`"
:disabled="togglingFormatEndpointId === endpoint.id || isEndpointFormatConversionDisabled"
@click="handleToggleFormatConversion(endpoint)"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
</span>
<!-- 端点代理 -->
<Popover
:open="endpointProxyPopoverOpen[endpoint.id] || false"
@update:open="(open: boolean) => handleEndpointProxyPopoverToggle(endpoint.id, open)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="endpointProxyNodeId(endpoint) ? 'text-blue-500' : ''"
:disabled="savingEndpointId === endpoint.id"
:title="getEndpointProxyTitle(endpoint)"
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3 !z-[90]"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">端点代理节点</span>
<Button
v-if="endpointProxyNodeId(endpoint)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingEndpointId === endpoint.id"
@click="clearEndpointProxy(endpoint)"
>
清除
</Button>
</div>
<ProxyNodeSelect
:model-value="endpointProxyNodeId(endpoint)"
trigger-class="h-8"
@update:model-value="setEndpointProxy(endpoint, $event)"
/>
<p class="text-[10px] text-muted-foreground">
{{ endpointProxyNodeId(endpoint) ? '当前使用端点级代理' : '未设置时按提供商代理、系统代理继续兜底' }}
</p>
</div>
</PopoverContent>
</Popover>
<!-- 上游流式三态按钮 -->
<Button
variant="ghost"
size="icon"
:class="getUpstreamStreamButtonClass(endpoint)"
:title="getUpstreamStreamTooltip(endpoint)"
:disabled="savingEndpointId === endpoint.id || isUpstreamStreamPolicyLocked(endpoint)"
@click="handleCycleUpstreamStream(endpoint)"
>
<Radio class="w-3.5 h-3.5" />
</Button>
<!-- 启用/停用 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="endpoint.is_active ? '停用' : '启用'"
:disabled="togglingEndpointId === endpoint.id"
@click="handleToggleEndpoint(endpoint)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<!-- 删除 -->
<Button
v-if="!isFixedProvider"
variant="ghost"
size="icon"
class="h-7 w-7 hover:text-destructive"
title="删除"
:disabled="deletingEndpointId === endpoint.id"
@click="handleDeleteEndpoint(endpoint)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 卡片内容 -->
<div class="p-4 space-y-4">
<!-- URL 配置区 -->
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
<div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
:model-value="getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url"
:placeholder="getEndpointBaseUrlPlaceholder(endpoint.api_format)"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'url', v)"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getDisplayedPath(endpoint)"
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
<p
v-if="getEndpointDefaultPath(endpoint)"
class="text-[10px] text-muted-foreground truncate"
:title="getEndpointDefaultPath(endpoint)"
>
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
</p>
</div>
</div>
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
<div
v-if="!isEndpointConfigReadOnly && hasUrlChanges(endpoint)"
class="flex items-center gap-1 shrink-0"
>
<Button
variant="ghost"
size="icon"
class="h-9 w-9"
title="保存"
:disabled="savingEndpointId === endpoint.id"
@click="saveEndpoint(endpoint)"
>
<Check class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-9 w-9"
title="撤销"
@click="resetEndpointChanges(endpoint)"
>
<RotateCcw class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 请求/响应规则(请求头、请求体和响应头规则) -->
<Collapsible
v-if="!isEndpointConfigReadOnly"
v-model:open="endpointRulesExpanded[endpoint.id]"
>
<div class="flex items-center gap-2">
<!-- 有规则时显示可折叠的触发器 -->
<CollapsibleTrigger
v-if="getTotalRulesCount(endpoint) > 0"
as-child
>
<button
type="button"
class="flex items-center gap-2 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors"
>
<ChevronRight
class="w-4 h-4 transition-transform text-muted-foreground"
:class="{ 'rotate-90': endpointRulesExpanded[endpoint.id] }"
/>
<span class="text-sm font-medium">请求/响应规则</span>
<Badge
variant="secondary"
class="text-xs"
>
{{ getTotalRulesCount(endpoint) }} 条
</Badge>
</button>
</CollapsibleTrigger>
<!-- 没有规则时只显示标题 -->
<span
v-else
class="text-sm text-muted-foreground py-1.5"
>
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="hasRulePanelChanges(endpoint)"
variant="ghost"
size="icon"
class="h-7 w-7"
title="保存规则"
:disabled="savingEndpointId === endpoint.id"
@click="saveEndpoint(endpoint)"
>
<Save class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="isEndpointRulesJsonMode(endpoint.id) ? '切回表单视图' : '切到 JSON 视图'"
@click="toggleEndpointRulesJsonMode(endpoint)"
>
<Code2 class="w-3 h-3 mr-1" />
{{ isEndpointRulesJsonMode(endpoint.id) ? '表单' : 'JSON' }}
</Button>
<Button
v-if="isEndpointRulesJsonMode(endpoint.id)"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
title="格式化 JSON"
@click="formatEndpointRulesJson(endpoint.id)"
>
<AlignLeft class="w-3 h-3 mr-1" />
格式化
</Button>
<Button
v-if="!isEndpointRulesJsonMode(endpoint.id)"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
title="添加请求头规则"
@click="handleAddEndpointRule(endpoint.id)"
>
<Plus class="w-3 h-3 mr-1" />
请求头
</Button>
<Button
v-if="!isEndpointRulesJsonMode(endpoint.id)"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
title="添加请求体规则"
@click="handleAddEndpointBodyRule(endpoint.id)"
>
<Plus class="w-3 h-3 mr-1" />
请求体
</Button>
<Button
v-if="!isEndpointRulesJsonMode(endpoint.id)"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
title="添加响应头规则"
@click="handleAddEndpointResponseRule(endpoint.id)"
>
<Plus class="w-3 h-3 mr-1" />
响应头
</Button>
<Button
v-if="isFixedProvider && hasDefaultBodyRules(endpoint.api_format)"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
title="重置请求体"
:disabled="resettingDefaultRulesEndpointId === endpoint.id"
@click="handleResetBodyRulesToDefault(endpoint)"
>
<RotateCcw class="w-3 h-3 mr-1" />
重置请求体
</Button>
</div>
</div>
<CollapsibleContent class="pt-3">
<div
v-if="isEndpointRulesJsonMode(endpoint.id)"
class="space-y-2"
>
<Textarea
:model-value="getEndpointRulesJsonDraft(endpoint)"
class="min-h-[220px] font-mono text-xs leading-relaxed"
spellcheck="false"
placeholder="{ &quot;header_rules&quot;: [], &quot;body_rules&quot;: [], &quot;response_header_rules&quot;: [] }"
@update:model-value="(value) => updateEndpointRulesJsonDraft(endpoint.id, value)"
/>
<div
v-if="endpointRulesJsonError[endpoint.id]"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{{ endpointRulesJsonError[endpoint.id] }}
</div>
</div>
<div
v-else
class="space-y-2"
>
<div
v-if="getEndpointRulesCount(endpoint) > 1 || getEndpointBodyRulesCount(endpoint) > 1 || getEndpointResponseRulesCount(endpoint) > 1"
class="flex items-center gap-1.5 text-xs text-muted-foreground px-2"
>
<GripVertical class="w-3.5 h-3.5" />
<span>拖拽左侧手柄可调整规则执行顺序</span>
</div>
<!-- 请求头规则列表 - 主题色边框 -->
<template
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
:key="`header-${index}`"
>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
:class="[
!rule.enabled ? 'opacity-60 border-primary/25 bg-muted/20' : '',
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
]"
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
>
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span
class="text-[10px] font-semibold text-primary shrink-0"
title="请求头"
>H</span>
<Switch
:model-value="rule.enabled"
class="shrink-0 scale-75 origin-center"
:title="rule.enabled ? '已启用,点击禁用这条请求头规则' : '已禁用,点击启用这条请求头规则'"
@update:model-value="(v: boolean) => updateEndpointRuleEnabled(endpoint.id, index, v)"
/>
<Select
:model-value="rule.action"
:open="ruleSelectOpen[`${endpoint.id}-${index}`]"
@update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
@update:open="(v) => handleRuleSelectOpen(endpoint.id, index, v)"
>
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">
覆写
</SelectItem>
<SelectItem value="drop">
删除
</SelectItem>
<SelectItem value="rename">
重命名
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.condition ? 'text-primary' : ''"
title="条件触发"
@click="toggleEndpointRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<template v-if="rule.action === 'set'">
<Input
:model-value="rule.key"
placeholder="名称"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
/>
<span class="text-muted-foreground text-xs">=</span>
<Input
:model-value="rule.value"
placeholder="值"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
/>
</template>
<template v-else-if="rule.action === 'drop'">
<Input
:model-value="rule.key"
placeholder="要删除的名称"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
/>
</template>
<template v-else-if="rule.action === 'rename'">
<Input
:model-value="rule.from"
placeholder="原名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.to"
placeholder="新名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
/>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="removeEndpointRule(endpoint.id, index)"
>
<X class="w-3 h-3" />
</Button>
</div>
<EndpointConditionEditor
v-if="rule.condition"
:model-value="rule.condition"
path-hint="请求体字段路径"
removable
@update:model-value="(condition) => updateEndpointRuleCondition(endpoint.id, index, condition)"
@remove="clearEndpointRuleCondition(endpoint.id, index)"
/>
</template>
<!-- 响应头规则列表 -->
<template
v-for="(rule, index) in getEndpointEditResponseRules(endpoint.id)"
:key="`response-header-${index}`"
>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-sky-500/60 bg-muted/30"
:class="[
!rule.enabled ? 'opacity-60 border-sky-500/25 bg-muted/20' : '',
isResponseRuleDragging(endpoint.id, index) ? 'opacity-60 border-sky-500 bg-sky-500/5' : '',
isResponseRuleDragOver(endpoint.id, index) ? 'ring-1 ring-sky-500/40 bg-sky-500/10' : ''
]"
@dragover.prevent="handleResponseRuleDragOver(endpoint.id, index)"
@dragleave="handleResponseRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleResponseRuleDrop(endpoint.id, index)"
>
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleResponseRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleResponseRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span
class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 shrink-0"
title="响应头"
>R</span>
<Switch
:model-value="rule.enabled"
class="shrink-0 scale-75 origin-center"
:title="rule.enabled ? '已启用,点击禁用这条响应头规则' : '已禁用,点击启用这条响应头规则'"
@update:model-value="(v: boolean) => updateEndpointResponseRuleEnabled(endpoint.id, index, v)"
/>
<Select
:model-value="rule.action"
:open="responseRuleSelectOpen[`${endpoint.id}-${index}`]"
@update:model-value="(v) => updateEndpointResponseRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
@update:open="(v) => handleResponseRuleSelectOpen(endpoint.id, index, v)"
>
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">
覆写
</SelectItem>
<SelectItem value="drop">
删除
</SelectItem>
<SelectItem value="rename">
重命名
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.condition ? 'text-primary' : ''"
title="条件触发"
@click="toggleEndpointResponseRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<template v-if="rule.action === 'set'">
<Input
:model-value="rule.key"
placeholder="响应头名称"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'key', v)"
/>
<span class="text-muted-foreground text-xs">=</span>
<Input
:model-value="rule.value"
placeholder="值"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'value', v)"
/>
</template>
<template v-else-if="rule.action === 'drop'">
<Input
:model-value="rule.key"
placeholder="要删除的响应头"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'key', v)"
/>
</template>
<template v-else-if="rule.action === 'rename'">
<Input
:model-value="rule.from"
placeholder="原名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'from', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.to"
placeholder="新名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'to', v)"
/>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="removeEndpointResponseRule(endpoint.id, index)"
>
<X class="w-3 h-3" />
</Button>
</div>
<EndpointConditionEditor
v-if="rule.condition"
:model-value="rule.condition"
path-hint="响应体字段路径"
removable
@update:model-value="(condition) => updateEndpointResponseRuleCondition(endpoint.id, index, condition)"
@remove="clearEndpointResponseRuleCondition(endpoint.id, index)"
/>
</template>
<div
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
class="flex items-center gap-1 text-xs text-muted-foreground px-2"
>
<span><code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引 / <code class="bg-muted px-1 rounded">[*]</code> 通配符;值为 JSON 格式</span>
<div class="flex-1" />
<Popover
:open="bodyRuleHelpOpenEndpointId === endpoint.id"
@update:open="(v: boolean) => setBodyRuleHelpOpen(endpoint.id, v)"
>
<PopoverTrigger as-child>
<button
type="button"
class="shrink-0 h-6 w-6 inline-flex items-center justify-center rounded-md hover:bg-muted/60"
title="规则说明"
aria-label="规则说明"
>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground/60" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
:side-offset="6"
class="w-80 p-3 !z-[90]"
>
<div class="text-xs space-y-2">
<div>
<div class="font-medium mb-0.5">
路径语法
</div>
<div class="text-muted-foreground">
<code>metadata.user_id</code> 嵌套字段<br>
<code>messages[0].content</code> 数组索引<br>
<code>tools[*].name</code> 通配符(遍历所有元素)<br>
<code>tools[0-4].name</code> 范围(遍历索引 0~4)<br>
<code>config\.v1.key</code> 转义点号
</div>
</div>
<div>
<div class="font-medium mb-0.5">
值格式 (JSON)
</div>
<div class="text-muted-foreground">
<code>123</code> 数字 / <code>"text"</code> 字符串 / <code>true</code> 布尔<br>
<code>{"k":"v"}</code> 对象 / <code>[1,2]</code> 数组 / <code>null</code><br>
<code v-pre>{{$original}}</code> 引用原值
</div>
</div>
<div>
<div class="font-medium mb-0.5">
条件运算符
</div>
<div class="text-muted-foreground">
<code>eq</code> <code>neq</code> 等于/不等于<br>
<code>gt</code> <code>lt</code> <code>gte</code> <code>lte</code> 大小比较<br>
<code>starts_with</code> <code>ends_with</code> <code>contains</code> 字符串匹配<br>
<code>matches</code> 正则匹配<br>
<code>exists</code> <code>not_exists</code> 字段存在性<br>
<code>in</code> 在列表中(值填 <code>["a","b"]</code>)<br>
<code>type_is</code> 类型判断(string/number/boolean/array/object/null)<br>
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素<br>
可切换 <code>请求体</code>/<code>请求头</code> 数据源,并支持 <code>ALL</code>/<code>ANY</code> 组合条件
</div>
</div>
<div class="text-muted-foreground">
规则按顺序执行;条件判断使用客户端原始请求体或原始请求头,不受前面规则修改影响。
</div>
<div class="text-muted-foreground">
条件默认按客户端原始请求体匹配;切到 <code>请求头</code> 时按客户端原始请求头匹配。
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- 请求体规则列表 - 次要色边框 -->
<template
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
:key="`body-${index}`"
>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
:class="[
!rule.enabled ? 'opacity-60 border-muted-foreground/25 bg-muted/20' : '',
isBodyRuleDragging(endpoint.id, index) ? 'opacity-60 border-muted-foreground/70 bg-muted/50' : '',
isBodyRuleDragOver(endpoint.id, index) ? 'ring-1 ring-muted-foreground/40 bg-muted/40' : ''
]"
@dragover.prevent="handleBodyRuleDragOver(endpoint.id, index)"
@dragleave="handleBodyRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleBodyRuleDrop(endpoint.id, index)"
>
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleBodyRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleBodyRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span
class="text-[10px] font-semibold text-muted-foreground shrink-0"
title="请求体"
>B</span>
<Switch
:model-value="rule.enabled"
class="shrink-0 scale-75 origin-center"
:title="rule.enabled ? '已启用,点击禁用这条请求体规则' : '已禁用,点击启用这条请求体规则'"
@update:model-value="(v: boolean) => updateEndpointBodyRuleEnabled(endpoint.id, index, v)"
/>
<Select
:model-value="rule.action"
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
@update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
>
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">
覆写
</SelectItem>
<SelectItem value="drop">
删除
</SelectItem>
<SelectItem value="rename">
重命名
</SelectItem>
<SelectItem value="append">
追加
</SelectItem>
<SelectItem value="insert">
插入
</SelectItem>
<SelectItem value="regex_replace">
正则替换
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.condition ? 'text-primary' : ''"
title="条件触发"
@click="toggleBodyRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<template v-if="rule.action === 'set'">
<Input
:model-value="rule.path"
placeholder="字段路径(如 metadata.user_id)"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<span class="text-muted-foreground text-xs">=</span>
<Input
:model-value="rule.value"
placeholder="123 / &quot;text&quot; / {{$original}}"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getBodySetValueValidationTip(rule)"
/>
</template>
<template v-else-if="rule.action === 'drop'">
<Input
:model-value="rule.path"
placeholder="要删除的字段路径"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
</template>
<template v-else-if="rule.action === 'rename'">
<Input
:model-value="rule.from"
placeholder="原路径"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'from', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.to"
placeholder="新路径"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
/>
</template>
<template v-else-if="rule.action === 'append'">
<Input
:model-value="rule.path"
placeholder="数组路径(如 messages)"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<span class="text-muted-foreground text-xs">+=</span>
<Input
:model-value="rule.value"
placeholder="值 (JSON)"
size="sm"
class="flex-[3] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getBodySetValueValidationTip(rule)"
/>
</template>
<template v-else-if="rule.action === 'insert'">
<Input
:model-value="rule.path"
placeholder="数组路径(如 messages)"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<Input
:model-value="rule.index"
placeholder="位置"
size="sm"
class="w-14 h-7 text-xs shrink-0"
title="插入位置(支持负数)"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'index', v)"
/>
<Input
:model-value="rule.value"
placeholder="值 (JSON)"
size="sm"
class="flex-[3] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getBodySetValueValidationTip(rule)"
/>
</template>
<template v-else-if="rule.action === 'regex_replace'">
<Input
:model-value="rule.path"
placeholder="字段路径"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<Input
:model-value="rule.pattern"
placeholder="正则"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs font-mono"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'pattern', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.replacement"
placeholder="替换为"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'replacement', v)"
/>
<Input
:model-value="rule.flags"
placeholder="ims"
size="sm"
class="w-12 h-7 text-xs shrink-0 font-mono"
title="正则标志:i=忽略大小写 m=多行 s=dotall"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'flags', v)"
/>
<Input
:model-value="rule.count"
placeholder="全部"
size="sm"
class="w-14 h-7 text-xs shrink-0"
title="替换次数;留空=默认全部,0=全部"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'count', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getRegexPatternValidation(rule) === true ? 'text-green-600' : getRegexPatternValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getRegexPatternValidationTip(rule)"
/>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="removeEndpointBodyRule(endpoint.id, index)"
>
<X class="w-3 h-3" />
</Button>
</div>
<EndpointConditionEditor
v-if="rule.condition"
:model-value="rule.condition"
:path-hint="getBodyRuleConditionPathPlaceholder(rule.path)"
removable
@update:model-value="(condition) => updateEndpointBodyRuleCondition(endpoint.id, index, condition)"
@remove="clearEndpointBodyRuleCondition(endpoint.id, index)"
/>
</template>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
</div>
<!-- 添加新端点 -->
<div
v-if="!isFixedProvider && availableFormats.length > 0"
class="rounded-lg border border-dashed p-3"
>
<!-- 卡片头部:API 格式选择 + 添加按钮 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-dashed">
<Select
v-model="newEndpoint.api_format"
:open="formatSelectOpen"
@update:open="handleFormatSelectOpen"
>
<SelectTrigger class="h-auto w-auto gap-1.5 !border-0 bg-transparent !shadow-none p-0 font-medium rounded-none flex-row-reverse !ring-0 !ring-offset-0 !outline-none [&>svg]:h-4 [&>svg]:w-4 [&>svg]:opacity-70">
<SelectValue placeholder="选择格式..." />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
class="h-7 px-3"
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
@click="handleAddEndpoint"
>
添加
</Button>
</div>
<!-- 卡片内容:URL 配置 -->
<div class="p-4">
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
<div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="newEndpoint.base_url"
size="sm"
:placeholder="newEndpointBaseUrlPlaceholder"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
v-model="newEndpoint.custom_path"
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
<p
v-if="newEndpointDefaultPath"
class="text-[10px] text-muted-foreground truncate"
:title="newEndpointDefaultPath"
>
当前默认路径:{{ newEndpointDefaultPath }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="localEndpoints.length === 0 && availableFormats.length === 0"
class="text-center py-8 text-muted-foreground"
>
<p>所有 API 格式都已配置</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="handleClose"
>
关闭
</Button>
</template>
</Dialog>
<!-- 删除端点确认弹窗 -->
<AlertDialog
:model-value="deleteConfirmOpen"
title="删除端点"
:description="deleteConfirmDescription"
confirm-text="删除"
cancel-text="取消"
type="danger"
@update:model-value="deleteConfirmOpen = $event"
@confirm="confirmDeleteEndpoint"
@cancel="deleteConfirmOpen = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Dialog,
Button,
Input,
Textarea,
Label,
Badge,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Switch,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Popover,
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 { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
createEndpoint,
getDefaultBodyRules,
updateEndpoint,
deleteEndpoint,
type ProviderEndpoint,
type ProviderWithEndpointsSummary,
type HeaderRule,
type BodyRule,
type BodyRuleRegexReplace,
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import {
conditionEquals,
conditionToEditable,
createEmptyConditionLeaf,
editableConditionToApi,
getBodyRuleConditionPathPlaceholder,
type EditableConditionNode,
validateEditableCondition,
} from './endpoint-rule-condition'
// 编辑用的规则类型(统一的可编辑结构)
interface EditableRule {
action: 'set' | 'drop' | 'rename'
enabled: boolean
key: string // set/drop 用
value: string // set 用
from: string // rename 用
to: string // rename 用
condition: EditableConditionNode | null
}
// 编辑用的请求体规则类型
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace'
interface EditableBodyRule {
action: BodyRuleAction
enabled: boolean
path: string // set/drop/append/insert/regex_replace 用
value: string // set/append/insert 用(JSON 格式)
from: string // rename 用
to: string // rename 用
index: string // insert 用(字符串输入,保存时解析为 int)
pattern: string // regex_replace 用
replacement: string // regex_replace 用
flags: string // regex_replace 用(i/m/s)
count: string // regex_replace 用(空=默认全部;0=全部)
condition: EditableConditionNode | null
}
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
interface EndpointEditState {
url: string
path: string
upstreamStreamPolicy: string
rules: EditableRule[]
responseRules: EditableRule[]
bodyRules: EditableBodyRule[]
}
interface EndpointRulesJsonPayload {
header_rules: HeaderRule[]
body_rules: BodyRule[]
response_header_rules: HeaderRule[]
}
const props = defineProps<{
modelValue: boolean
provider: ProviderWithEndpointsSummary | null
endpoints?: ProviderEndpoint[]
systemFormatConversionEnabled?: boolean
providerFormatConversionEnabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpointCreated': []
'endpointUpdated': []
}>()
// 计算端点级格式转换是否应该被禁用
const isEndpointFormatConversionDisabled = computed(() => {
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
})
// 获取禁用提示
const formatConversionDisabledTooltip = computed(() => {
if (props.systemFormatConversionEnabled) {
return '请先关闭系统级开关'
}
if (props.providerFormatConversionEnabled) {
return '请先关闭提供商级开关'
}
return ''
})
const { success, error: showError } = useToast()
const proxyNodesStore = useProxyNodesStore()
// 规则 Select 的展开状态(与 Collapsible 分开管理)
const ruleSelectOpen = ref<Record<string, boolean>>({})
const responseRuleSelectOpen = ref<Record<string, boolean>>({})
// 打开规则选择器时关闭其他所有下拉
function handleRuleSelectOpen(endpointId: string, index: number, open: boolean) {
if (open) {
formatSelectOpen.value = false
// 关闭其他 Select
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
}
ruleSelectOpen.value[`${endpointId}-${index}`] = open
}
// 打开响应头规则选择器时关闭其他所有下拉
function handleResponseRuleSelectOpen(endpointId: string, index: number, open: boolean) {
if (open) {
formatSelectOpen.value = false
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
}
responseRuleSelectOpen.value[`${endpointId}-${index}`] = open
}
// 打开格式选择器时关闭其他所有下拉
function handleFormatSelectOpen(open: boolean) {
if (open) {
// 关闭所有规则 Select
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
}
formatSelectOpen.value = open
}
// 打开请求体规则选择器时关闭其他所有下拉
function handleBodyRuleSelectOpen(endpointId: string, index: number, open: boolean) {
if (open) {
formatSelectOpen.value = false
// 关闭所有 Select
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
}
bodyRuleSelectOpen.value[`${endpointId}-${index}`] = open
}
function clearHeaderRuleSelectOpen(endpointId: string) {
Object.keys(ruleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete ruleSelectOpen.value[key]
}
})
}
function clearResponseRuleSelectOpen(endpointId: string) {
Object.keys(responseRuleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete responseRuleSelectOpen.value[key]
}
})
}
function clearBodyRuleSelectOpen(endpointId: string) {
Object.keys(bodyRuleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete bodyRuleSelectOpen.value[key]
}
})
}
function isHeaderRuleDragging(endpointId: string, index: number): boolean {
return headerRuleDraggedIndex.value[endpointId] === index
}
function isHeaderRuleDragOver(endpointId: string, index: number): boolean {
return headerRuleDragOverIndex.value[endpointId] === index
}
function isBodyRuleDragging(endpointId: string, index: number): boolean {
return bodyRuleDraggedIndex.value[endpointId] === index
}
function isResponseRuleDragging(endpointId: string, index: number): boolean {
return responseRuleDraggedIndex.value[endpointId] === index
}
function isBodyRuleDragOver(endpointId: string, index: number): boolean {
return bodyRuleDragOverIndex.value[endpointId] === index
}
function isResponseRuleDragOver(endpointId: string, index: number): boolean {
return responseRuleDragOverIndex.value[endpointId] === index
}
function clearHeaderRuleDragState(endpointId: string) {
headerRuleDraggedIndex.value[endpointId] = null
headerRuleDragOverIndex.value[endpointId] = null
}
function clearResponseRuleDragState(endpointId: string) {
responseRuleDraggedIndex.value[endpointId] = null
responseRuleDragOverIndex.value[endpointId] = null
}
function clearBodyRuleDragState(endpointId: string) {
bodyRuleDraggedIndex.value[endpointId] = null
bodyRuleDragOverIndex.value[endpointId] = null
}
function handleHeaderRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditRules(endpointId)
if (!rules[index]) return
headerRuleDraggedIndex.value[endpointId] = index
headerRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `header:${endpointId}:${index}`)
}
}
function handleHeaderRuleDragOver(endpointId: string, index: number) {
const dragged = headerRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
headerRuleDragOverIndex.value[endpointId] = index
}
function handleHeaderRuleDragLeave(endpointId: string, index: number) {
if (headerRuleDragOverIndex.value[endpointId] === index) {
headerRuleDragOverIndex.value[endpointId] = null
}
}
function handleHeaderRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = headerRuleDraggedIndex.value[endpointId]
clearHeaderRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearHeaderRuleSelectOpen(endpointId)
}
function handleHeaderRuleDragEnd(endpointId: string) {
clearHeaderRuleDragState(endpointId)
}
function handleResponseRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditResponseRules(endpointId)
if (!rules[index]) return
responseRuleDraggedIndex.value[endpointId] = index
responseRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `response:${endpointId}:${index}`)
}
}
function handleResponseRuleDragOver(endpointId: string, index: number) {
const dragged = responseRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
responseRuleDragOverIndex.value[endpointId] = index
}
function handleResponseRuleDragLeave(endpointId: string, index: number) {
if (responseRuleDragOverIndex.value[endpointId] === index) {
responseRuleDragOverIndex.value[endpointId] = null
}
}
function handleResponseRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = responseRuleDraggedIndex.value[endpointId]
clearResponseRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditResponseRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearResponseRuleSelectOpen(endpointId)
}
function handleResponseRuleDragEnd(endpointId: string) {
clearResponseRuleDragState(endpointId)
}
function handleBodyRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditBodyRules(endpointId)
if (!rules[index]) return
bodyRuleDraggedIndex.value[endpointId] = index
bodyRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `body:${endpointId}:${index}`)
}
}
function handleBodyRuleDragOver(endpointId: string, index: number) {
const dragged = bodyRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
bodyRuleDragOverIndex.value[endpointId] = index
}
function handleBodyRuleDragLeave(endpointId: string, index: number) {
if (bodyRuleDragOverIndex.value[endpointId] === index) {
bodyRuleDragOverIndex.value[endpointId] = null
}
}
function handleBodyRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = bodyRuleDraggedIndex.value[endpointId]
clearBodyRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditBodyRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearBodyRuleSelectOpen(endpointId)
}
function handleBodyRuleDragEnd(endpointId: string) {
clearBodyRuleDragState(endpointId)
}
// 状态
const addingEndpoint = ref(false)
const savingEndpointId = ref<string | null>(null)
const resettingDefaultRulesEndpointId = ref<string | null>(null)
const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
const togglingFormatEndpointId = ref<string | null>(null)
const formatSelectOpen = ref(false)
const endpointProxyPopoverOpen = ref<Record<string, boolean>>({})
// 删除确认弹窗状态
const deleteConfirmOpen = ref(false)
const endpointToDelete = ref<ProviderEndpoint | null>(null)
// 请求规则折叠状态
const endpointRulesExpanded = ref<Record<string, boolean>>({})
const endpointRulesJsonMode = ref<Record<string, boolean>>({})
const endpointRulesJsonDraft = ref<Record<string, string>>({})
const endpointRulesJsonError = ref<Record<string, string | null>>({})
const endpointRulesJsonDirty = ref<Record<string, boolean>>({})
// 请求体规则 Select 的展开状态
const bodyRuleSelectOpen = ref<Record<string, boolean>>({})
// 请求体规则说明 Popover 的展开状态
const bodyRuleHelpOpenEndpointId = ref<string | null>(null)
// 规则拖拽状态(按 endpoint 维度)
const headerRuleDraggedIndex = ref<Record<string, number | null>>({})
const headerRuleDragOverIndex = ref<Record<string, number | null>>({})
const responseRuleDraggedIndex = ref<Record<string, number | null>>({})
const responseRuleDragOverIndex = ref<Record<string, number | null>>({})
const bodyRuleDraggedIndex = ref<Record<string, number | null>>({})
const bodyRuleDragOverIndex = ref<Record<string, number | null>>({})
function setBodyRuleHelpOpen(endpointId: string, open: boolean) {
bodyRuleHelpOpenEndpointId.value = open ? endpointId : null
}
// 每个端点的编辑状态(内联编辑)
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
const defaultBodyRulesByFormat = ref<Record<string, BodyRule[]>>({})
const defaultBodyRulesLoaded = ref<Record<string, boolean>>({})
const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({})
// 系统保留的 header 名称(不允许用户设置)
const RESERVED_HEADERS = new Set([
'authorization',
'x-api-key',
'x-goog-api-key',
'content-type',
'content-length',
'host',
])
const RESERVED_RESPONSE_HEADERS = new Set([
'content-length',
])
const RESPONSE_HEADER_RULES_CONFIG_KEY = 'response_header_rules'
const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules'
// 系统保留的 body 字段名(不允许用户设置)
const RESERVED_BODY_FIELDS = new Set([
'stream',
])
const BODY_RULE_JSON_ACTIONS = new Set(['set', 'drop', 'rename', 'append', 'insert', 'regex_replace'])
const CONDITION_JSON_OPS = new Set(['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'starts_with', 'ends_with', 'contains', 'matches', 'exists', 'not_exists', 'in', 'type_is'])
const CONDITION_JSON_SOURCES = new Set(['body', 'current', 'original', 'request_headers', 'headers'])
function isJsonObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function readJsonRulesArray(root: Record<string, unknown>, key: keyof EndpointRulesJsonPayload, label: string): { value: unknown[]; error: string | null } {
const raw = root[key]
if (raw === undefined || raw === null) return { value: [], error: null }
if (!Array.isArray(raw)) return { value: [], error: `${label} 必须是数组或 null` }
return { value: raw, error: null }
}
function validateJsonCondition(rule: Record<string, unknown>, label: string, index: number): string | null {
const raw = rule.condition
if (raw === undefined || raw === null) return null
if (!isJsonObject(raw)) return `${label}第 ${index + 1} 条:condition 必须是对象`
const shapeError = validateJsonConditionShape(raw, `${label}第 ${index + 1} 条:condition`)
if (shapeError) return shapeError
const editable = conditionToEditable(raw as BodyRule['condition'])
const err = validateEditableCondition(editable)
return err ? `${label}第 ${index + 1} 条:${err}` : null
}
function validateJsonConditionShape(condition: Record<string, unknown>, label: string): string | null {
if (Object.prototype.hasOwnProperty.call(condition, 'all')) {
if (!Array.isArray(condition.all)) return `${label}.all 必须是数组`
for (let i = 0; i < condition.all.length; i++) {
const child = condition.all[i]
if (!isJsonObject(child)) return `${label}.all[${i}] 必须是对象`
const err = validateJsonConditionShape(child, `${label}.all[${i}]`)
if (err) return err
}
return null
}
if (Object.prototype.hasOwnProperty.call(condition, 'any')) {
if (!Array.isArray(condition.any)) return `${label}.any 必须是数组`
for (let i = 0; i < condition.any.length; i++) {
const child = condition.any[i]
if (!isJsonObject(child)) return `${label}.any[${i}] 必须是对象`
const err = validateJsonConditionShape(child, `${label}.any[${i}]`)
if (err) return err
}
return null
}
if (typeof condition.path !== 'string') return `${label}.path 必须是字符串`
if (typeof condition.op !== 'string' || !CONDITION_JSON_OPS.has(condition.op)) {
return `${label}.op 无效`
}
if (condition.source !== undefined && (typeof condition.source !== 'string' || !CONDITION_JSON_SOURCES.has(condition.source))) {
return `${label}.source 无效`
}
return null
}
function requireJsonString(rule: Record<string, unknown>, key: string, label: string, index: number): string | null {
return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串`
}
function validateHeaderRuleJson(rule: unknown, label: string, index: number): string | null {
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
}
const action = rule.action
if (action !== 'set' && action !== 'drop' && action !== 'rename') {
return `${label}第 ${index + 1} 条:action 必须是 set/drop/rename`
}
if (action === 'set') {
return requireJsonString(rule, 'key', label, index)
|| requireJsonString(rule, 'value', label, index)
|| validateJsonCondition(rule, label, index)
}
if (action === 'drop') {
return requireJsonString(rule, 'key', label, index)
|| validateJsonCondition(rule, label, index)
}
return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index)
|| validateJsonCondition(rule, label, index)
}
function validateBodyRuleJson(rule: unknown, label: string, index: number): string | null {
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
}
const action = typeof rule.action === 'string' ? rule.action : ''
if (!BODY_RULE_JSON_ACTIONS.has(action)) {
return `${label}第 ${index + 1} 条:action 无效`
}
if (action === 'set' || action === 'append') {
return requireJsonString(rule, 'path', label, index)
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`)
|| validateJsonCondition(rule, label, index)
}
if (action === 'drop') {
return requireJsonString(rule, 'path', label, index)
|| validateJsonCondition(rule, label, index)
}
if (action === 'rename') {
return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index)
|| validateJsonCondition(rule, label, index)
}
if (action === 'insert') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
if (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数`
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空`
return validateJsonCondition(rule, label, index)
}
if (action === 'regex_replace') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
if (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index)
if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串`
if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串`
if (rule.count !== undefined && !Number.isInteger(rule.count)) return `${label}第 ${index + 1} 条:count 必须是整数`
return validateJsonCondition(rule, label, index)
}
return `${label}第 ${index + 1} 条:action 无效`
}
function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonPayload | null; error: string | null } {
const raw = draft.trim()
if (!raw) {
return { value: { header_rules: [], body_rules: [], response_header_rules: [] }, error: null }
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error: unknown) {
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
}
if (!isJsonObject(parsed)) return { value: null, error: '规则 JSON 必须是对象' }
const header = readJsonRulesArray(parsed, 'header_rules', 'header_rules')
if (header.error) return { value: null, error: header.error }
const body = readJsonRulesArray(parsed, 'body_rules', 'body_rules')
if (body.error) return { value: null, error: body.error }
const response = readJsonRulesArray(parsed, 'response_header_rules', 'response_header_rules')
if (response.error) return { value: null, error: response.error }
for (let i = 0; i < header.value.length; i++) {
const error = validateHeaderRuleJson(header.value[i], 'header_rules ', i)
if (error) return { value: null, error }
}
for (let i = 0; i < body.value.length; i++) {
const error = validateBodyRuleJson(body.value[i], 'body_rules ', i)
if (error) return { value: null, error }
}
for (let i = 0; i < response.value.length; i++) {
const error = validateHeaderRuleJson(response.value[i], 'response_header_rules ', i)
if (error) return { value: null, error }
}
return {
value: {
header_rules: header.value as HeaderRule[],
body_rules: body.value as BodyRule[],
response_header_rules: response.value as HeaderRule[],
},
error: null,
}
}
function applyEndpointRulesJsonDraft(
endpointId: string,
options: { notify?: boolean; notifyError?: boolean } = {},
): boolean {
const notifyError = options.notifyError !== false
const parsed = parseEndpointRulesJsonDraft(endpointRulesJsonDraft.value[endpointId] ?? '')
if (!parsed.value) {
endpointRulesJsonError.value[endpointId] = parsed.error
if (notifyError) showError(parsed.error || '规则 JSON 无效')
return false
}
const state = ensureEndpointEditState(endpointId)
if (!state) {
endpointRulesJsonError.value[endpointId] = '端点编辑状态不可用'
if (notifyError) showError('端点编辑状态不可用')
return false
}
state.rules = editableHeaderRulesFromRules(parsed.value.header_rules)
state.bodyRules = editableBodyRulesFromRules(parsed.value.body_rules)
state.responseRules = editableHeaderRulesFromRules(parsed.value.response_header_rules)
clearHeaderRuleSelectOpen(endpointId)
clearResponseRuleSelectOpen(endpointId)
clearBodyRuleSelectOpen(endpointId)
clearHeaderRuleDragState(endpointId)
clearResponseRuleDragState(endpointId)
clearBodyRuleDragState(endpointId)
const validationError = getHeaderValidationErrorForEndpoint(endpointId)
|| getResponseHeaderValidationErrorForEndpoint(endpointId)
|| getBodyValidationErrorForEndpoint(endpointId)
if (validationError) {
endpointRulesJsonError.value[endpointId] = validationError
if (notifyError) showError(validationError)
return false
}
endpointRulesJsonDraft.value[endpointId] = stringifyEndpointRulesJsonPayload(parsed.value)
endpointRulesJsonError.value[endpointId] = null
endpointRulesJsonDirty.value[endpointId] = false
if (options.notify !== false) success('JSON 规则已应用')
return true
}
// {{$original}} 占位符处理
const ORIGINAL_PLACEHOLDER = '{{$original}}'
const ORIGINAL_SENTINEL = '__AETHER_ORIGINAL__'
// 将 {{$original}} 替换为合法 JSON 以便 JSON.parse 校验
// 处理三种写法:裸占位符 {{$original}}、带引号 "{{$original}}"、引号内拼接 "prefix_{{$original}}_suffix"
function prepareValueForJsonParse(raw: string): string {
// Step 1: 纯文本替换占位符为 sentinel
const result = raw.replaceAll(ORIGINAL_PLACEHOLDER, ORIGINAL_SENTINEL)
// Step 2: 尝试直接 parse(占位符在引号内时已经是合法 JSON)
try { JSON.parse(result); return result } catch { /* sentinel not in valid JSON position */ }
// Step 3: 有裸 sentinel 不在引号内,需要扫描并补引号
let out = ''
let inStr = false
let i = 0
while (i < result.length) {
if (result[i] === '\\' && inStr) {
out += result[i] + (result[i + 1] || '')
i += 2
continue
}
if (result[i] === '"') {
inStr = !inStr
out += result[i]
i++
continue
}
if (!inStr && result.startsWith(ORIGINAL_SENTINEL, i)) {
out += `"${ ORIGINAL_SENTINEL }"`
i += ORIGINAL_SENTINEL.length
continue
}
out += result[i]
i++
}
return out
}
// 递归还原: 将 sentinel 字符串还原为 {{$original}}
function restoreOriginalPlaceholder(value: unknown): unknown {
if (typeof value === 'string') {
if (value === ORIGINAL_SENTINEL) return ORIGINAL_PLACEHOLDER
if (value.includes(ORIGINAL_SENTINEL)) {
return value.replaceAll(ORIGINAL_SENTINEL, ORIGINAL_PLACEHOLDER)
}
return value
}
if (Array.isArray(value)) return value.map(item => restoreOriginalPlaceholder(item))
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) result[k] = restoreOriginalPlaceholder(v)
return result
}
return value
}
function parseBodyRulePathParts(path: string): string[] | null {
const raw = path.trim()
if (!raw) return null
const parts: string[] = []
let current = ''
for (let i = 0; i < raw.length; i++) {
const ch = raw[i]
// 支持 \. 转义字面量点号;其他反斜杠组合按字面量保留
if (ch === '\\' && i + 1 < raw.length && raw[i + 1] === '.') {
current += '.'
i++
continue
}
if (ch === '.') {
if (!current) return null // 禁止空段:.a / a. / a..b
parts.push(current)
current = ''
continue
}
current += ch
}
if (!current) return null
parts.push(current)
return parts
}
function initBodyRuleSetValueForEditor(value: unknown): { value: string } {
if (value === undefined) return { value: '' }
// 所有值都用 JSON 格式回显
try {
return { value: JSON.stringify(value) }
} catch {
return { value: String(value) }
}
}
// 内部状态
const internalOpen = computed(() => props.modelValue)
const isFixedProvider = computed(() => {
const t = props.provider?.provider_type
return !!t && t !== 'custom'
})
const isEndpointConfigReadOnly = computed(() => {
return (props.provider?.provider_type || '').trim().toLowerCase() === 'gemini_cli'
})
// 新端点表单
const newEndpoint = ref({
api_format: '',
base_url: '',
custom_path: '',
})
// API 格式列表
const apiFormats = ref<Array<{ value: string; label: string; default_path: string }>>([])
const fallbackEndpointBaseUrl = 'https://api.example.com'
// 本地端点列表
const localEndpoints = ref<ProviderEndpoint[]>([])
// 可用的格式(未添加的)
const availableFormats = computed(() => {
const existingFormats = localEndpoints.value.map(e => e.api_format)
return apiFormats.value.filter(f => !existingFormats.includes(f.value))
})
// 删除确认弹窗描述
const deleteConfirmDescription = computed(() => {
if (!endpointToDelete.value) return ''
const formatLabel = formatApiFormat(endpointToDelete.value.api_format)
return `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
})
function defaultBodyRulesCacheKey(apiFormat: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
return providerType ? `${apiFormat}:${providerType}` : apiFormat
}
function hasDefaultBodyRules(apiFormat: string): boolean {
const cacheKey = defaultBodyRulesCacheKey(apiFormat)
if (!defaultBodyRulesLoaded.value[cacheKey]) return false
return (defaultBodyRulesByFormat.value[cacheKey]?.length || 0) > 0
}
async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false): Promise<BodyRule[]> {
if (!apiFormat) return []
const providerType = (props.provider?.provider_type || '').toLowerCase()
const cacheKey = defaultBodyRulesCacheKey(apiFormat)
if (!force && defaultBodyRulesLoaded.value[cacheKey]) {
return defaultBodyRulesByFormat.value[cacheKey] || []
}
if (loadingDefaultBodyRulesByFormat.value[cacheKey]) {
return defaultBodyRulesByFormat.value[cacheKey] || []
}
loadingDefaultBodyRulesByFormat.value[cacheKey] = true
try {
const response = await getDefaultBodyRules(apiFormat, providerType || undefined)
const rules = response.body_rules || []
defaultBodyRulesByFormat.value[cacheKey] = rules
defaultBodyRulesLoaded.value[cacheKey] = true
return rules
} catch (error: unknown) {
defaultBodyRulesByFormat.value[cacheKey] = []
defaultBodyRulesLoaded.value[cacheKey] = true
log.warn('加载默认请求体规则失败', { apiFormat, error })
return []
} finally {
loadingDefaultBodyRulesByFormat.value[cacheKey] = false
}
}
async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<void> {
const formats = Array.from(new Set(endpoints.map(e => e.api_format).filter(Boolean)))
await Promise.all(formats.map(fmt => loadDefaultBodyRulesForFormat(fmt)))
}
// 获取指定 API 格式的默认路径
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
return getDefaultEndpointPath({
apiFormat,
providerType,
baseUrl,
apiFormats: apiFormats.value,
})
}
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
}
function getEndpointBaseUrlPlaceholder(apiFormat: string): string {
const seedBaseUrl = (props.provider?.website || fallbackEndpointBaseUrl).trim()
return getDefaultEndpointBaseUrl({
apiFormat,
baseUrl: seedBaseUrl,
}) || seedBaseUrl
}
function getNewEndpointBaseUrl(): string {
const typedBaseUrl = newEndpoint.value.base_url.trim()
if (typedBaseUrl) return typedBaseUrl
return getDefaultEndpointBaseUrl({
apiFormat: newEndpoint.value.api_format,
baseUrl: props.provider?.website || '',
})
}
const newEndpointBaseUrlPlaceholder = computed(() => {
return getEndpointBaseUrlPlaceholder(newEndpoint.value.api_format)
})
function getDisplayedPath(endpoint: ProviderEndpoint): string {
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
// 读取端点的上游流式策略(endpoint.config.upstream_stream_policy)
function getEndpointUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
const cfg = endpoint.config || {}
const raw = (cfg.upstream_stream_policy ?? cfg.upstreamStreamPolicy ?? cfg.upstream_stream) as unknown
if (raw === null || raw === undefined) return 'auto'
if (typeof raw === 'boolean') return raw ? 'force_stream' : 'force_non_stream'
const s = String(raw).trim().toLowerCase()
if (!s || s === 'auto' || s === 'follow' || s === 'client' || s === 'default') return 'auto'
if (s === 'force_stream' || s === 'stream' || s === 'sse' || s === 'true' || s === '1') return 'force_stream'
if (s === 'force_non_stream' || s === 'force_sync' || s === 'non_stream' || s === 'sync' || s === 'false' || s === '0') return 'force_non_stream'
return 'auto'
}
function endpointProxyNodeId(endpoint: ProviderEndpoint): string {
if (endpoint.proxy?.enabled === false) return ''
return endpoint.proxy?.node_id?.trim() || ''
}
function getEndpointProxyNodeName(endpoint: ProviderEndpoint): string {
const nodeId = endpointProxyNodeId(endpoint)
if (!nodeId) return '未知节点'
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
return node ? node.name : `${nodeId.slice(0, 8)}...`
}
function getEndpointProxyTitle(endpoint: ProviderEndpoint): string {
const nodeId = endpointProxyNodeId(endpoint)
return nodeId ? `端点代理: ${getEndpointProxyNodeName(endpoint)}` : '设置端点代理节点'
}
function handleEndpointProxyPopoverToggle(endpointId: string, open: boolean) {
endpointProxyPopoverOpen.value[endpointId] = open
if (open) {
proxyNodesStore.ensureLoaded()
}
}
function replaceLocalEndpoint(updated: ProviderEndpoint) {
localEndpoints.value = localEndpoints.value.map(endpoint =>
endpoint.id === updated.id ? updated : endpoint,
)
}
async function setEndpointProxy(endpoint: ProviderEndpoint, nodeId: string) {
const normalizedNodeId = nodeId.trim()
if (!normalizedNodeId) return
savingEndpointId.value = endpoint.id
try {
const updated = await updateEndpoint(endpoint.id, {
proxy: { node_id: normalizedNodeId, enabled: true },
})
replaceLocalEndpoint(updated)
endpointProxyPopoverOpen.value[endpoint.id] = false
success('端点代理已更新')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '更新代理失败'), '错误')
} finally {
savingEndpointId.value = null
}
}
async function clearEndpointProxy(endpoint: ProviderEndpoint) {
savingEndpointId.value = endpoint.id
try {
const updated = await updateEndpoint(endpoint.id, { proxy: null })
replaceLocalEndpoint(updated)
endpointProxyPopoverOpen.value[endpoint.id] = false
success('端点代理已清除')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '清除代理失败'), '错误')
} finally {
savingEndpointId.value = null
}
}
function emptyHeaderRule(): EditableRule {
return { action: 'set', enabled: true, key: '', value: '', from: '', to: '', condition: null }
}
function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): EditableRule[] {
if (!Array.isArray(rules)) return []
const editableRules: EditableRule[] = []
for (const rule of rules) {
if (rule.action === 'set') {
editableRules.push({ ...emptyHeaderRule(), action: 'set', enabled: rule.enabled !== false, key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'drop') {
editableRules.push({ ...emptyHeaderRule(), action: 'drop', enabled: rule.enabled !== false, key: rule.key, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
editableRules.push({ ...emptyHeaderRule(), action: 'rename', enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
}
}
return editableRules
}
function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
return {
action,
enabled: true,
path: '',
value: '',
from: '',
to: '',
index: '',
pattern: '',
replacement: '',
flags: '',
count: '',
condition: null,
}
}
function editableBodyRulesFromRules(rules: BodyRule[] | null | undefined): EditableBodyRule[] {
const bodyRules: EditableBodyRule[] = []
if (!Array.isArray(rules)) return bodyRules
for (const rule of rules) {
if (rule.action === 'set') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'drop') {
bodyRules.push({ ...emptyBodyRule('drop'), enabled: rule.enabled !== false, path: rule.path, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
bodyRules.push({ ...emptyBodyRule('rename'), enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'append') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'insert') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'regex_replace') {
bodyRules.push({
...emptyBodyRule('regex_replace'),
enabled: rule.enabled !== false,
path: rule.path || '',
pattern: rule.pattern || '',
replacement: rule.replacement || '',
flags: rule.flags || '',
count: rule.count === undefined || rule.count === null ? '' : String(rule.count),
condition: conditionToEditable(rule.condition),
})
}
}
return bodyRules
}
// 初始化端点的编辑状态
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
const rules = editableHeaderRulesFromRules(endpoint.header_rules)
const responseRules = editableHeaderRulesFromRules(getEndpointResponseHeaderRules(endpoint))
const bodyRules = editableBodyRulesFromRules(endpoint.body_rules)
return {
url: endpoint.base_url,
path: endpoint.custom_path || '',
upstreamStreamPolicy: getEndpointUpstreamStreamPolicy(endpoint),
rules,
responseRules,
bodyRules,
}
}
// 获取端点的编辑状态
function getEndpointEditState(endpointId: string): EndpointEditState | undefined {
return endpointEditStates.value[endpointId]
}
function ensureEndpointEditState(endpointId: string): EndpointEditState | null {
if (!endpointEditStates.value[endpointId]) {
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
endpointEditStates.value[endpointId] = initEndpointEditState(endpoint)
}
}
return endpointEditStates.value[endpointId] ?? null
}
function isEndpointRulesJsonMode(endpointId: string): boolean {
return endpointRulesJsonMode.value[endpointId] === true
}
function buildEndpointRulesJsonPayload(endpointId: string): EndpointRulesJsonPayload {
const state = ensureEndpointEditState(endpointId)
return {
header_rules: state ? (rulesToHeaderRules(state.rules) ?? []) : [],
body_rules: state ? (rulesToBodyRules(state.bodyRules) ?? []) : [],
response_header_rules: state ? (rulesToHeaderRules(state.responseRules) ?? []) : [],
}
}
function stringifyEndpointRulesJsonPayload(payload: EndpointRulesJsonPayload): string {
return JSON.stringify(payload, null, 2)
}
function refreshEndpointRulesJsonDraft(endpointId: string) {
endpointRulesJsonDraft.value[endpointId] = stringifyEndpointRulesJsonPayload(
buildEndpointRulesJsonPayload(endpointId),
)
endpointRulesJsonError.value[endpointId] = null
endpointRulesJsonDirty.value[endpointId] = false
}
function enterEndpointRulesJsonMode(endpoint: ProviderEndpoint) {
ensureEndpointEditState(endpoint.id)
refreshEndpointRulesJsonDraft(endpoint.id)
endpointRulesJsonMode.value[endpoint.id] = true
endpointRulesExpanded.value[endpoint.id] = true
}
function toggleEndpointRulesJsonMode(endpoint: ProviderEndpoint) {
if (isEndpointRulesJsonMode(endpoint.id)) {
if (endpointRulesJsonDirty.value[endpoint.id] && !applyEndpointRulesJsonDraft(endpoint.id, { notify: false })) {
return
}
endpointRulesJsonMode.value[endpoint.id] = false
return
}
enterEndpointRulesJsonMode(endpoint)
}
function getEndpointRulesJsonDraft(endpoint: ProviderEndpoint): string {
if (endpointRulesJsonDraft.value[endpoint.id] === undefined) {
endpointRulesJsonDraft.value[endpoint.id] = stringifyEndpointRulesJsonPayload(
buildEndpointRulesJsonPayload(endpoint.id),
)
}
return endpointRulesJsonDraft.value[endpoint.id]
}
function updateEndpointRulesJsonDraft(endpointId: string, value: string) {
endpointRulesJsonDraft.value[endpointId] = value
endpointRulesJsonDirty.value[endpointId] = true
endpointRulesJsonError.value[endpointId] = null
}
function formatEndpointRulesJson(endpointId: string) {
const currentDraft = endpointRulesJsonDraft.value[endpointId] ?? ''
const parsed = parseEndpointRulesJsonDraft(endpointRulesJsonDraft.value[endpointId] ?? '')
if (!parsed.value) {
endpointRulesJsonError.value[endpointId] = parsed.error
return
}
const formattedDraft = stringifyEndpointRulesJsonPayload(parsed.value)
endpointRulesJsonDraft.value[endpointId] = formattedDraft
endpointRulesJsonError.value[endpointId] = null
if (formattedDraft !== currentDraft) {
endpointRulesJsonDirty.value[endpointId] = true
}
}
// 更新端点字段
function updateEndpointField(endpointId: string, field: 'url' | 'path', value: string) {
ensureEndpointEditState(endpointId)
if (endpointEditStates.value[endpointId]) {
endpointEditStates.value[endpointId][field] = value
}
}
// 获取端点的编辑规则
function getEndpointEditRules(endpointId: string): EditableRule[] {
const state = endpointEditStates.value[endpointId]
if (state) {
return state.rules
}
// 从原始端点加载
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
const newState = initEndpointEditState(endpoint)
endpointEditStates.value[endpointId] = newState
return newState.rules
}
return []
}
function getEndpointResponseHeaderRules(endpoint: ProviderEndpoint): HeaderRule[] {
const raw = (endpoint as ProviderEndpoint & { response_header_rules?: unknown }).response_header_rules
?? endpoint.config?.[RESPONSE_HEADER_RULES_CONFIG_KEY]
?? endpoint.config?.[RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY]
return Array.isArray(raw) ? (raw as HeaderRule[]) : []
}
function getEndpointEditResponseRules(endpointId: string): EditableRule[] {
const state = endpointEditStates.value[endpointId]
if (state) {
return state.responseRules
}
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
const newState = initEndpointEditState(endpoint)
endpointEditStates.value[endpointId] = newState
return newState.responseRules
}
return []
}
// 添加规则(同时自动展开折叠)
function handleAddEndpointRule(endpointId: string) {
const rules = getEndpointEditRules(endpointId)
rules.push(emptyHeaderRule())
// 自动展开折叠
endpointRulesExpanded.value[endpointId] = true
}
function handleAddEndpointResponseRule(endpointId: string) {
const rules = getEndpointEditResponseRules(endpointId)
rules.push(emptyHeaderRule())
endpointRulesExpanded.value[endpointId] = true
}
// 删除规则
function removeEndpointRule(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
rules.splice(index, 1)
clearHeaderRuleDragState(endpointId)
clearHeaderRuleSelectOpen(endpointId)
}
function removeEndpointResponseRule(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
rules.splice(index, 1)
clearResponseRuleDragState(endpointId)
clearResponseRuleSelectOpen(endpointId)
}
// 更新规则类型
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
const currentCondition = rules[index].condition
const currentEnabled = rules[index].enabled
rules[index] = { ...emptyHeaderRule(), action, enabled: currentEnabled, condition: currentCondition }
}
}
function updateEndpointResponseRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
const currentCondition = rules[index].condition
const currentEnabled = rules[index].enabled
rules[index] = { ...emptyHeaderRule(), action, enabled: currentEnabled, condition: currentCondition }
}
}
function updateEndpointRuleEnabled(endpointId: string, index: number, enabled: boolean) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index].enabled = enabled
}
}
function updateEndpointResponseRuleEnabled(endpointId: string, index: number, enabled: boolean) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].enabled = enabled
}
}
// 更新规则字段
function updateEndpointRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
function updateEndpointResponseRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
function toggleEndpointRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
}
}
function toggleEndpointResponseRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
}
}
function updateEndpointRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index].condition = condition
}
}
function updateEndpointResponseRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = condition
}
}
function clearEndpointRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index].condition = null
}
}
function clearEndpointResponseRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = null
}
}
// 验证规则 key(针对特定端点)
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null
if (RESERVED_HEADERS.has(trimmedKey)) {
return `"${key}" 是系统保留的请求头`
}
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedKey) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedKey)
)
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 验证 rename from
function validateRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'drop' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
)
if (duplicate >= 0) {
return '该请求头已被其他规则处理'
}
return null
}
// 验证 rename to
function validateRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) {
return `"${to}" 是系统保留的请求头`
}
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedTo) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 获取端点的请求头规则数量(有效的规则)
function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.rules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
}).length
}
return endpoint.header_rules?.length || 0
}
function getEndpointResponseRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.responseRules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
}).length
}
return getEndpointResponseHeaderRules(endpoint).length
}
// 检查端点是否有任何规则(包括正在编辑的空规则)
function _hasAnyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.rules.length > 0
}
return (endpoint.header_rules?.length || 0) > 0
}
// ========== 请求体规则相关函数 ==========
// 获取端点的编辑请求体规则
function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
const state = endpointEditStates.value[endpointId]
if (state) {
return state.bodyRules
}
// 从原始端点加载
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
const newState = initEndpointEditState(endpoint)
endpointEditStates.value[endpointId] = newState
return newState.bodyRules
}
return []
}
// 添加请求体规则(同时自动展开折叠)
function handleAddEndpointBodyRule(endpointId: string) {
const rules = getEndpointEditBodyRules(endpointId)
rules.push(emptyBodyRule('set'))
// 自动展开折叠
endpointRulesExpanded.value[endpointId] = true
}
// 删除请求体规则
function removeEndpointBodyRule(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId)
rules.splice(index, 1)
clearBodyRuleDragState(endpointId)
clearBodyRuleSelectOpen(endpointId)
}
// 更新请求体规则类型
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
const currentCondition = rules[index].condition
const currentEnabled = rules[index].enabled
rules[index] = { ...emptyBodyRule(action), enabled: currentEnabled, condition: currentCondition }
}
}
function updateEndpointBodyRuleEnabled(endpointId: string, index: number, enabled: boolean) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index].enabled = enabled
}
}
// 更新请求体规则字段
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'count', value: string) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
function toggleBodyRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
}
}
function updateEndpointBodyRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index].condition = condition
}
}
function clearEndpointBodyRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index].condition = null
}
}
// 验证请求体规则 path(针对特定端点)
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
const raw = path.trim()
if (!raw) return null
// 基础格式校验;对含 [N] 的路径,取方括号前的部分做 dot 校验
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
if (!parts) {
return '路径格式无效'
}
// 提取顶层 key(去除数组索引部分)
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedPath = raw.toLowerCase()
const rules = getEndpointEditBodyRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
// 任意一方启用了条件,则不视为冲突(条件可能互斥,真正冲突在运行时处理)
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled && !currentRule.condition && !r.condition && (
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === normalizedPath) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedPath)
)
)
if (duplicate >= 0) {
return '字段路径重复'
}
return null
}
// 验证请求体 rename from
function validateBodyRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
const raw = from.trim()
if (!raw) return null
const parts = parseBodyRulePathParts(raw)
if (!parts) {
return '路径格式无效(不允许 .a / a. / a..b)'
}
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedFrom = raw.toLowerCase()
const rules = getEndpointEditBodyRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled && !currentRule.condition && !r.condition &&
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedFrom) ||
(r.action === 'drop' && r.path.trim().toLowerCase() === normalizedFrom) ||
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
)
if (duplicate >= 0) {
return '该路径已被其他规则处理'
}
return null
}
// 验证请求体 rename to
function validateBodyRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
const raw = to.trim()
if (!raw) return null
const parts = parseBodyRulePathParts(raw)
if (!parts) {
return '路径格式无效(不允许 .a / a. / a..b)'
}
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const normalizedTo = raw.toLowerCase()
const rules = getEndpointEditBodyRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled && !currentRule.condition && !r.condition &&
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedTo) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
)
if (duplicate >= 0) {
return '字段路径重复'
}
return null
}
function validateBodySetValue(rule: EditableBodyRule): string | null {
if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
const raw = rule.value.trim()
if (!raw) return '值不能为空'
try {
JSON.parse(prepareValueForJsonParse(raw))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
return `JSON 格式错误:${msg}`
}
return null
}
// 获取值验证状态:true=有效, false=无效, null=空
function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
const raw = rule.value.trim()
if (!raw) return null
try {
JSON.parse(prepareValueForJsonParse(raw))
return true
} catch {
return false
}
}
// 正则表达式验证状态:true=有效, false=无效, null=空
function getRegexPatternValidation(rule: EditableBodyRule): boolean | null {
if (rule.action !== 'regex_replace') return null
const pattern = rule.pattern.trim()
if (!pattern) return null
try {
new RegExp(pattern)
// 校验 flags
const flags = rule.flags.trim()
if (flags) {
const validFlags = new Set(['i', 'm', 's'])
for (const f of flags) {
if (!validFlags.has(f)) return false
}
}
return true
} catch {
return false
}
}
// 获取正则验证提示
function getRegexPatternValidationTip(rule: EditableBodyRule): string {
const validation = getRegexPatternValidation(rule)
if (validation === null) return '输入正则表达式'
if (validation === true) return '有效的正则表达式'
try {
new RegExp(rule.pattern.trim())
// 正则有效但 flags 无效
return '无效的 flags(仅允许 i/m/s)'
} catch (err: unknown) {
return err instanceof Error ? err.message : String(err)
}
}
// 获取验证提示
function getBodySetValueValidationTip(rule: EditableBodyRule): string {
const validation = getBodySetValueValidation(rule)
if (validation === null) return '点击验证 JSON'
if (validation === true) {
const parsed = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim())))
const type = Array.isArray(parsed) ? '数组' : typeof parsed === 'object' && parsed !== null ? '对象' : typeof parsed === 'string' ? '字符串' : typeof parsed === 'number' ? '数字' : typeof parsed === 'boolean' ? '布尔' : 'null'
return `有效的 JSON (${type})`
}
try {
JSON.parse(prepareValueForJsonParse(rule.value.trim()))
return ''
} catch (err: unknown) {
return err instanceof Error ? err.message : String(err)
}
}
function isStrictIntegerString(value: string): boolean {
return /^-?\d+$/.test(value.trim())
}
function isStrictNonNegativeIntegerString(value: string): boolean {
return /^\d+$/.test(value.trim())
}
// 判断请求体规则是否有效(必填字段已填写)
function isBodyRuleEffective(r: EditableBodyRule): boolean {
switch (r.action) {
case 'set':
case 'drop':
return !!r.path.trim()
case 'rename':
return !!(r.from.trim() && r.to.trim())
case 'append':
return !!r.path.trim()
case 'insert':
return !!(r.path.trim() && r.index.trim())
case 'regex_replace':
return !!(r.path.trim() && r.pattern.trim())
default:
return false
}
}
// 获取端点的请求体规则数量(有效的规则)
function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.bodyRules.filter(isBodyRuleEffective).length
}
return endpoint.body_rules?.length || 0
}
// 检查端点是否有任何请求体规则(包括正在编辑的空规则)
function _hasAnyBodyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.bodyRules.length > 0
}
return (endpoint.body_rules?.length || 0) > 0
}
// 获取端点的总规则数量(请求头 + 请求体 + 响应头)
function getTotalRulesCount(endpoint: ProviderEndpoint): number {
return getEndpointRulesCount(endpoint) + getEndpointBodyRulesCount(endpoint) + getEndpointResponseRulesCount(endpoint)
}
// 格式化请求头规则的显示标签
function _formatHeaderRuleLabel(rule: EditableRule): string {
if (rule.action === 'set') {
if (!rule.key) return '(未设置)'
return `${rule.key}=${rule.value || '...'}`
} else if (rule.action === 'drop') {
if (!rule.key) return '(未设置)'
return `-${rule.key}`
} else if (rule.action === 'rename') {
if (!rule.from || !rule.to) return '(未设置)'
return `${rule.from}→${rule.to}`
}
return '(未知)'
}
// 格式化请求体规则的显示标签
function _formatBodyRuleLabel(rule: EditableBodyRule): string {
if (rule.action === 'set') {
if (!rule.path) return '(未设置)'
return `${rule.path}=${rule.value || '...'}`
} else if (rule.action === 'drop') {
if (!rule.path) return '(未设置)'
return `-${rule.path}`
} else if (rule.action === 'rename') {
if (!rule.from || !rule.to) return '(未设置)'
return `${rule.from}→${rule.to}`
} else if (rule.action === 'append') {
if (!rule.path) return '(未设置)'
return `${rule.path}[]+=${rule.value || '...'}`
} else if (rule.action === 'insert') {
if (!rule.path) return '(未设置)'
const idx = rule.index?.trim() || '末尾'
return `${rule.path}[${idx}]+=${rule.value || '...'}`
} else if (rule.action === 'regex_replace') {
if (!rule.path || !rule.pattern) return '(未设置)'
const flags = rule.flags.trim()
const count = rule.count.trim()
return `${rule.path}: s/${rule.pattern}/${rule.replacement || ''}/${flags}${count ? ` ×${count}` : ''}`
}
return '(未知)'
}
// 检查端点请求体规则是否有修改
function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
const originalRules = endpoint.body_rules || []
const editedRules = state.bodyRules.filter(isBodyRuleEffective)
if (editedRules.length !== originalRules.length) return true
for (let i = 0; i < editedRules.length; i++) {
const edited = editedRules[i]
const original = originalRules[i]
if (!original) return true
if (edited.action !== original.action) return true
if (edited.enabled !== (original.enabled !== false)) return true
if (edited.action === 'set' && original.action === 'set') {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true
} else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.path !== original.path) return true
} else if (edited.action === 'rename' && original.action === 'rename') {
if (edited.from !== original.from || edited.to !== original.to) return true
} else if (edited.action === 'append' && original.action === 'append') {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true
} else if (edited.action === 'insert' && original.action === 'insert') {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.index !== String(original.index ?? '')) return true
if (edited.value !== baseline.value) return true
} else if (edited.action === 'regex_replace' && original.action === 'regex_replace') {
if (edited.path !== original.path) return true
if (edited.pattern !== (original.pattern ?? '')) return true
if (edited.replacement !== (original.replacement ?? '')) return true
if (edited.flags !== (original.flags ?? '')) return true
if (edited.count !== (original.count === undefined || original.count === null ? '' : String(original.count))) return true
}
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
}
return false
}
// 将可编辑请求体规则数组转换为 API 需要的 BodyRule[]
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
const result: BodyRule[] = []
for (const rule of rules) {
const condition = editableConditionToApi(rule.condition)
const common = { ...(rule.enabled ? {} : { enabled: false }), ...(condition ? { condition } : {}) }
if (rule.action === 'set' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
result.push({ action: 'set', path: rule.path.trim(), value, ...common })
} else if (rule.action === 'drop' && rule.path.trim()) {
result.push({ action: 'drop', path: rule.path.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim(), ...common })
} else if (rule.action === 'append' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
result.push({ action: 'append', path: rule.path.trim(), value, ...common })
} else if (rule.action === 'insert' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
if (!isStrictIntegerString(rule.index)) continue
const idx = parseInt(rule.index.trim(), 10)
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...common })
} else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
const entry: BodyRuleRegexReplace = {
action: 'regex_replace',
path: rule.path.trim(),
pattern: rule.pattern,
replacement: rule.replacement || '',
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
...(isStrictNonNegativeIntegerString(rule.count) ? { count: parseInt(rule.count.trim(), 10) } : {}),
}
result.push({ ...entry, ...common })
}
}
return result.length > 0 ? result : null
}
function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
const rules = getEndpointEditBodyRules(endpointId)
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
if (!rule.enabled) continue
const prefix = `第 ${i + 1} 条请求体规则:`
if (rule.action === 'set' || rule.action === 'drop') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
if (rule.action === 'set') {
const valueErr = validateBodySetValue(rule)
if (valueErr) return `${prefix}${valueErr}`
}
} else if (rule.action === 'rename') {
const fromErr = validateBodyRenameFromForEndpoint(endpointId, rule.from, i)
if (fromErr) return `${prefix}${fromErr}`
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
if (toErr) return `${prefix}${toErr}`
} else if (rule.action === 'append') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
const valueErr = validateBodySetValue(rule)
if (valueErr) return `${prefix}${valueErr}`
} else if (rule.action === 'insert') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
const indexStr = rule.index.trim()
if (!indexStr) return `${prefix}插入位置不能为空`
if (!isStrictIntegerString(indexStr)) return `${prefix}位置必须为整数`
const valueErr = validateBodySetValue(rule)
if (valueErr) return `${prefix}${valueErr}`
} else if (rule.action === 'regex_replace') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
if (!rule.pattern.trim()) return `${prefix}正则表达式不能为空`
try {
new RegExp(rule.pattern.trim())
} catch (err: unknown) {
return `${prefix}正则表达式无效:${err instanceof Error ? err.message : String(err)}`
}
const flags = rule.flags.trim()
if (flags) {
const validFlags = new Set(['i', 'm', 's'])
for (const f of flags) {
if (!validFlags.has(f)) return `${prefix}flags 仅允许 i/m/s,非法字符: ${f}`
}
}
const count = rule.count.trim()
if (count) {
if (!isStrictNonNegativeIntegerString(count)) return `${prefix}替换次数必须是大于等于 0 的整数`
}
}
const conditionErr = validateEditableCondition(rule.condition)
if (conditionErr) return `${prefix}${conditionErr}`
}
return null
}
// 检查请求体规则是否有验证错误
function _hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
return !!getBodyValidationErrorForEndpoint(endpointId)
}
// 检查端点 URL/路径是否有修改
function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
if (state.url !== endpoint.base_url) return true
if (state.path !== (endpoint.custom_path || '')) return true
// 注:upstreamStreamPolicy 现在由头部按钮直接保存,无需在此检查
return false
}
// 检查端点规则是否有修改
function editableHeaderRulesChanged(edited: EditableRule[], originalRules: HeaderRule[]): boolean {
const editedRules = edited.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
})
if (editedRules.length !== originalRules.length) return true
for (let i = 0; i < editedRules.length; i++) {
const edited = editedRules[i]
const original = originalRules[i]
if (!original) return true
if (edited.action !== original.action) return true
if (edited.enabled !== (original.enabled !== false)) return true
if (edited.action === 'set' && original.action === 'set') {
if (edited.key !== original.key || edited.value !== (original.value || '')) return true
} else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.key !== original.key) return true
} else if (edited.action === 'rename' && original.action === 'rename') {
if (edited.from !== original.from || edited.to !== original.to) return true
}
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
}
return false
}
function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
return editableHeaderRulesChanged(state.rules, endpoint.header_rules || [])
}
function hasResponseHeaderRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
return editableHeaderRulesChanged(state.responseRules, getEndpointResponseHeaderRules(endpoint))
}
function hasRulePanelChanges(endpoint: ProviderEndpoint): boolean {
return hasRulesChanges(endpoint)
|| hasBodyRulesChanges(endpoint)
|| hasResponseHeaderRulesChanges(endpoint)
|| (isEndpointRulesJsonMode(endpoint.id) && endpointRulesJsonDirty.value[endpoint.id] === true)
}
// 检查端点是否有修改(URL、路径或规则)
// 注:当前模板直接使用各子函数,此聚合函数保留供未来使用
function _hasEndpointChanges(endpoint: ProviderEndpoint): boolean {
return hasUrlChanges(endpoint) || hasRulePanelChanges(endpoint)
}
// 重置端点修改
function resetEndpointChanges(endpoint: ProviderEndpoint) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
if (isEndpointRulesJsonMode(endpoint.id)) {
refreshEndpointRulesJsonDraft(endpoint.id)
} else {
endpointRulesJsonError.value[endpoint.id] = null
endpointRulesJsonDirty.value[endpoint.id] = false
}
}
async function handleResetBodyRulesToDefault(endpoint: ProviderEndpoint) {
resettingDefaultRulesEndpointId.value = endpoint.id
try {
const defaultRules = await loadDefaultBodyRulesForFormat(endpoint.api_format, true)
if (!defaultRules.length) {
showError('该端点没有默认请求体规则')
return
}
if (!endpointEditStates.value[endpoint.id]) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
}
const state = endpointEditStates.value[endpoint.id]
if (!state) return
const resetState = initEndpointEditState({
...endpoint,
body_rules: defaultRules,
})
state.bodyRules = resetState.bodyRules
endpointRulesExpanded.value[endpoint.id] = (state.rules.length + state.responseRules.length + state.bodyRules.length) > 0
clearBodyRuleDragState(endpoint.id)
clearBodyRuleSelectOpen(endpoint.id)
if (isEndpointRulesJsonMode(endpoint.id)) {
refreshEndpointRulesJsonDraft(endpoint.id)
}
success('已重置请求体为默认规则,请点击保存生效')
} catch (error: unknown) {
showError(parseApiError(error, '重置失败'), '错误')
} finally {
resettingDefaultRulesEndpointId.value = null
}
}
// 将可编辑规则数组转换为 API 需要的 HeaderRule[]
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
const result: HeaderRule[] = []
for (const rule of rules) {
const condition = editableConditionToApi(rule.condition)
const common = { ...(rule.enabled ? {} : { enabled: false }), ...(condition ? { condition } : {}) }
if (rule.action === 'set' && rule.key.trim()) {
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...common })
} else if (rule.action === 'drop' && rule.key.trim()) {
result.push({ action: 'drop', key: rule.key.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim(), ...common })
}
}
return result.length > 0 ? result : null
}
function endpointConfigWithResponseHeaderRules(endpoint: ProviderEndpoint, rules: HeaderRule[] | null): Record<string, unknown> | null {
const merged: Record<string, unknown> = { ...(endpoint.config || {}) }
delete merged[RESPONSE_HEADER_RULES_CONFIG_KEY]
delete merged[RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY]
if (rules && rules.length > 0) {
merged[RESPONSE_HEADER_RULES_CONFIG_KEY] = rules
}
return Object.keys(merged).length > 0 ? merged : null
}
function getHeaderValidationErrorForEndpoint(endpointId: string): string | null {
const rules = getEndpointEditRules(endpointId)
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
if (!rule.enabled) continue
const prefix = `第 ${i + 1} 条请求头规则:`
if (rule.action === 'set' || rule.action === 'drop') {
const err = validateRuleKeyForEndpoint(endpointId, rule.key, i)
if (err) return `${prefix}${err}`
} else if (rule.action === 'rename') {
const fromErr = validateRenameFromForEndpoint(endpointId, rule.from, i)
if (fromErr) return `${prefix}${fromErr}`
const toErr = validateRenameToForEndpoint(endpointId, rule.to, i)
if (toErr) return `${prefix}${toErr}`
}
const conditionErr = validateEditableCondition(rule.condition)
if (conditionErr) return `${prefix}${conditionErr}`
}
return null
}
function validateResponseHeaderNameForEndpoint(endpointId: string, name: string, index: number, field: 'key' | 'from' | 'to'): string | null {
const trimmedName = name.trim().toLowerCase()
if (!trimmedName) return null
if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) {
return `"${name}" 是系统保留的响应头`
}
const rules = getEndpointEditResponseRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
(r, i) => i !== index && r.enabled && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedName) ||
(r.action === 'rename' && (field === 'from'
? r.from.trim().toLowerCase() === trimmedName
: r.to.trim().toLowerCase() === trimmedName))
)
)
if (duplicate >= 0) {
return field === 'from' ? '该响应头已被其他规则处理' : '响应头名称重复'
}
return null
}
function getResponseHeaderValidationErrorForEndpoint(endpointId: string): string | null {
const rules = getEndpointEditResponseRules(endpointId)
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
if (!rule.enabled) continue
const prefix = `第 ${i + 1} 条响应头规则:`
if (rule.action === 'set' || rule.action === 'drop') {
const err = validateResponseHeaderNameForEndpoint(endpointId, rule.key, i, 'key')
if (err) return `${prefix}${err}`
} else if (rule.action === 'rename') {
const fromErr = validateResponseHeaderNameForEndpoint(endpointId, rule.from, i, 'from')
if (fromErr) return `${prefix}${fromErr}`
const toErr = validateResponseHeaderNameForEndpoint(endpointId, rule.to, i, 'to')
if (toErr) return `${prefix}${toErr}`
}
const conditionErr = validateEditableCondition(rule.condition)
if (conditionErr) return `${prefix}${conditionErr}`
}
return null
}
// 新端点选择的格式的默认路径
const newEndpointDefaultPath = computed(() => {
// 使用填写的 base_url;留空时使用按格式规范化后的 provider website。
const baseUrl = getNewEndpointBaseUrl()
return getDefaultPath(newEndpoint.value.api_format, baseUrl)
})
// 加载 API 格式列表
const loadApiFormats = async () => {
try {
const response = await adminApi.getApiFormats()
apiFormats.value = response.formats
} catch (error) {
log.error('加载API格式失败:', error)
}
}
onMounted(() => {
loadApiFormats()
})
// 监听 props 变化
watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
responseRuleSelectOpen.value = {}
bodyRuleSelectOpen.value = {}
headerRuleDraggedIndex.value = {}
headerRuleDragOverIndex.value = {}
responseRuleDraggedIndex.value = {}
responseRuleDragOverIndex.value = {}
bodyRuleDraggedIndex.value = {}
bodyRuleDragOverIndex.value = {}
endpointRulesJsonMode.value = {}
endpointRulesJsonDraft.value = {}
endpointRulesJsonError.value = {}
endpointRulesJsonDirty.value = {}
if (open) {
localEndpoints.value = [...(props.endpoints || [])]
// 清空编辑状态,重新从端点加载
endpointEditStates.value = {}
endpointRulesExpanded.value = {}
// 初始化每个端点的编辑状态,有规则时默认展开
for (const endpoint of localEndpoints.value) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
// 有规则时默认展开
const hasRules = (endpoint.header_rules?.length || 0) + getEndpointResponseHeaderRules(endpoint).length + (endpoint.body_rules?.length || 0) > 0
endpointRulesExpanded.value[endpoint.id] = hasRules
}
void preloadDefaultBodyRules(localEndpoints.value)
} else {
// 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
}
}, { immediate: true })
watch(() => props.endpoints, (endpoints) => {
if (props.modelValue) {
localEndpoints.value = [...(endpoints || [])]
// 初始化新添加端点的编辑状态
for (const endpoint of localEndpoints.value) {
if (!endpointEditStates.value[endpoint.id]) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
}
if (isEndpointRulesJsonMode(endpoint.id) && !endpointRulesJsonDirty.value[endpoint.id]) {
refreshEndpointRulesJsonDraft(endpoint.id)
}
}
const newFormats = localEndpoints.value
.filter(e => e.api_format && !defaultBodyRulesLoaded.value[defaultBodyRulesCacheKey(e.api_format)])
.map(e => ({ api_format: e.api_format }) as ProviderEndpoint)
if (newFormats.length) {
void preloadDefaultBodyRules(newFormats)
}
}
}, { deep: true })
// 保存端点
async function saveEndpoint(endpoint: ProviderEndpoint) {
if (isEndpointConfigReadOnly.value) return
if (isEndpointRulesJsonMode(endpoint.id) && endpointRulesJsonDirty.value[endpoint.id]) {
if (!applyEndpointRulesJsonDraft(endpoint.id, { notify: false })) return
}
const state = endpointEditStates.value[endpoint.id]
if (!state || !state.url) return
// 检查规则是否有验证错误
const headerErr = getHeaderValidationErrorForEndpoint(endpoint.id)
if (headerErr) {
showError(headerErr)
return
}
const responseHeaderErr = getResponseHeaderValidationErrorForEndpoint(endpoint.id)
if (responseHeaderErr) {
showError(responseHeaderErr)
return
}
// 检查请求体规则是否有验证错误
const bodyErr = getBodyValidationErrorForEndpoint(endpoint.id)
if (bodyErr) {
showError(bodyErr)
return
}
savingEndpointId.value = endpoint.id
try {
// 仅提交变更字段;fixed provider 锁定 base_url,但允许覆盖 custom_path。
const payload: Record<string, unknown> = {}
if (!isFixedProvider.value) {
if (state.url !== endpoint.base_url) payload.base_url = state.url
}
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
if (hasResponseHeaderRulesChanges(endpoint)) {
payload.config = endpointConfigWithResponseHeaderRules(
endpoint,
rulesToHeaderRules(state.responseRules),
)
}
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
// 注:upstreamStreamPolicy 现在由头部按钮直接保存,不在此处处理
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
success('端点已更新')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '更新失败'), '错误')
} finally {
savingEndpointId.value = null
}
}
// 切换格式转换(直接保存)
async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
const currentEnabled = endpoint.format_acceptance_config?.enabled || false
const newEnabled = !currentEnabled
togglingFormatEndpointId.value = endpoint.id
try {
await updateEndpoint(endpoint.id, {
format_acceptance_config: newEnabled ? { enabled: true } : null,
})
success(newEnabled ? '已启用格式转换' : '已关闭格式转换')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '操作失败'), '错误')
} finally {
togglingFormatEndpointId.value = null
}
}
// 获取上游流式按钮的当前状态(优先使用编辑状态)
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
if (isUpstreamStreamPolicyLocked(endpoint)) return 'force_stream'
const state = endpointEditStates.value[endpoint.id]
return state?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)
}
function isUpstreamStreamPolicyLocked(endpoint: ProviderEndpoint): boolean {
return (props.provider?.provider_type || '').toLowerCase() === 'codex'
&& normalizeEndpointApiFormat(endpoint.api_format) === 'openai:responses'
}
// 获取上游流式按钮的样式类
function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
if (isUpstreamStreamPolicyLocked(endpoint)) {
return 'h-7 w-7 text-primary/70 cursor-not-allowed'
}
const policy = getCurrentUpstreamStreamPolicy(endpoint)
const base = 'h-7 w-7'
if (policy === 'force_stream') return `${base} text-primary`
if (policy === 'force_non_stream') return `${base} text-destructive`
return `${base} text-muted-foreground` // auto - 跟随请求,淡色显示
}
// 获取上游流式按钮的提示文字
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
if (isUpstreamStreamPolicyLocked(endpoint)) return '固定流式(Codex OpenAI Responses,已锁定)'
const policy = getCurrentUpstreamStreamPolicy(endpoint)
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
return '跟随请求(点击切换为固定流式)'
}
// 循环切换上游流式策略并直接保存
async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
if (isUpstreamStreamPolicyLocked(endpoint)) return
const currentPolicy = getCurrentUpstreamStreamPolicy(endpoint)
let nextPolicy: string
let nextLabel: string
// 循环:auto -> force_stream -> force_non_stream -> auto
if (currentPolicy === 'auto') {
nextPolicy = 'force_stream'
nextLabel = '固定流式'
} else if (currentPolicy === 'force_stream') {
nextPolicy = 'force_non_stream'
nextLabel = '固定非流'
} else {
nextPolicy = 'auto'
nextLabel = '跟随请求'
}
savingEndpointId.value = endpoint.id
try {
const merged: Record<string, unknown> = { ...(endpoint.config || {}) }
// 清理旧的 key
delete merged.upstream_stream_policy
delete merged.upstreamStreamPolicy
delete merged.upstream_stream
if (nextPolicy !== 'auto') {
merged.upstream_stream_policy = nextPolicy
}
await updateEndpoint(endpoint.id, {
config: Object.keys(merged).length > 0 ? merged : null,
})
// 更新本地编辑状态
if (endpointEditStates.value[endpoint.id]) {
endpointEditStates.value[endpoint.id].upstreamStreamPolicy = nextPolicy
}
success(`已切换为${nextLabel}`)
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '操作失败'), '错误')
} finally {
savingEndpointId.value = null
}
}
// 添加端点
async function handleAddEndpoint() {
if (!props.provider || !newEndpoint.value.api_format) return
// 如果没有输入 base_url,使用按格式规范化后的提供商 website 作为默认值。
const baseUrl = getNewEndpointBaseUrl()
if (!baseUrl) {
showError('请输入 Base URL')
return
}
addingEndpoint.value = true
try {
await createEndpoint(props.provider.id, {
provider_id: props.provider.id,
api_format: newEndpoint.value.api_format,
base_url: baseUrl,
custom_path: newEndpoint.value.custom_path || undefined,
is_active: true,
})
success(`已添加 ${formatApiFormat(newEndpoint.value.api_format)} 端点`)
// 重置表单,保留 URL
newEndpoint.value = { api_format: '', base_url: baseUrl, custom_path: '' }
emit('endpointCreated')
} catch (error: unknown) {
showError(parseApiError(error, '添加失败'), '错误')
} finally {
addingEndpoint.value = false
}
}
// 切换端点启用状态
async function handleToggleEndpoint(endpoint: ProviderEndpoint) {
togglingEndpointId.value = endpoint.id
try {
const newStatus = !endpoint.is_active
await updateEndpoint(endpoint.id, { is_active: newStatus })
success(newStatus ? '端点已启用' : '端点已停用')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '操作失败'), '错误')
} finally {
togglingEndpointId.value = null
}
}
// 删除端点 - 打开确认弹窗
function handleDeleteEndpoint(endpoint: ProviderEndpoint) {
endpointToDelete.value = endpoint
deleteConfirmOpen.value = true
}
// 确认删除端点
async function confirmDeleteEndpoint() {
if (!endpointToDelete.value) return
const endpoint = endpointToDelete.value
deleteConfirmOpen.value = false
deletingEndpointId.value = endpoint.id
try {
await deleteEndpoint(endpoint.id)
success(`已删除 ${formatApiFormat(endpoint.api_format)} 端点`)
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '删除失败'), '错误')
} finally {
deletingEndpointId.value = null
endpointToDelete.value = null
}
}
// 关闭对话框
function handleDialogUpdate(value: boolean) {
emit('update:modelValue', value)
}
function handleClose() {
emit('update:modelValue', false)
}
</script>