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

3416 lines
138 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
:model-value="internalOpen"
title="端点管理"
:description="`管理 ${provider?.name} 的 API 端点`"
:icon="Settings"
size="2xl"
2025-12-10 20:52:44 +08:00
@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 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>
2026-04-27 20:05:43 +08:00
<!-- 端点代理 -->
<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="provider?.website || 'https://api.example.com'"
: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="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
</div>
</div>
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
<div
2026-02-05 15:57:52 +08:00
v-if="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>
2026-05-06 01:29:42 +08:00
<!-- 请求/响应规则(请求头、请求体和响应头规则) -->
<Collapsible 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] }"
/>
2026-05-06 01:29:42 +08:00
<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"
>
2026-05-06 01:29:42 +08:00
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<Button
2026-05-06 21:30:13 +08:00
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
2026-05-06 21:30:13 +08:00
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
2026-05-06 21:30:13 +08:00
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>
2026-05-06 01:29:42 +08:00
<Button
2026-05-06 21:30:13 +08:00
v-if="!isEndpointRulesJsonMode(endpoint.id)"
2026-05-06 01:29:42 +08:00
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">
2026-05-06 21:30:13 +08:00
<div
v-if="isEndpointRulesJsonMode(endpoint.id)"
class="space-y-2"
>
<Textarea
:model-value="getEndpointRulesJsonDraft(endpoint)"
2026-05-06 21:45:37 +08:00
class="min-h-[220px] font-mono text-xs leading-relaxed"
2026-05-06 21:30:13 +08:00
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
2026-05-06 01:29:42 +08:00
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="[
2026-05-07 00:48:15 +08:00
!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>
2026-05-07 00:48:15 +08:00
<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>
2026-05-06 01:29:42 +08:00
<!-- 响应头规则列表 -->
<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="[
2026-05-07 00:48:15 +08:00
!rule.enabled ? 'opacity-60 border-sky-500/25 bg-muted/20' : '',
2026-05-06 01:29:42 +08:00
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>
2026-05-07 00:48:15 +08:00
<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)"
/>
2026-05-06 01:29:42 +08:00
<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="[
2026-05-07 00:48:15 +08:00
!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>
2026-05-07 00:48:15 +08:00
<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)"
>
2026-05-06 22:12:21 +08:00
<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>
2025-12-10 20:52:44 +08:00
</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="provider?.website || 'https://api.example.com'"
/>
</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 || '留空使用默认'"
/>
</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>
2025-12-10 20:52:44 +08:00
<template #footer>
<Button
variant="outline"
@click="handleClose"
2025-12-10 20:52:44 +08:00
>
关闭
2025-12-10 20:52:44 +08:00
</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"
/>
2025-12-10 20:52:44 +08:00
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Dialog,
Button,
Input,
2026-05-06 21:30:13 +08:00
Textarea,
Label,
Badge,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
2026-05-07 00:48:15 +08:00
Switch,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui'
2026-05-06 21:30:13 +08:00
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft } from 'lucide-vue-next'
2025-12-10 20:52:44 +08:00
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'
2026-04-27 20:05:43 +08:00
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
2026-04-27 20:05:43 +08:00
import { useProxyNodesStore } from '@/stores/proxy-nodes'
2025-12-10 20:52:44 +08:00
import {
createEndpoint,
getDefaultBodyRules,
2025-12-10 20:52:44 +08:00
updateEndpoint,
deleteEndpoint,
2025-12-10 20:52:44 +08:00
type ProviderEndpoint,
type ProviderWithEndpointsSummary,
type HeaderRule,
type BodyRule,
type BodyRuleRegexReplace,
2025-12-10 20:52:44 +08:00
} 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'
2025-12-10 20:52:44 +08:00
// 编辑用的规则类型(统一的可编辑结构)
interface EditableRule {
action: 'set' | 'drop' | 'rename'
2026-05-07 00:48:15 +08:00
enabled: boolean
key: string // set/drop 用
value: string // set 用
from: string // rename 用
to: string // rename 用
condition: EditableConditionNode | null
}
// 编辑用的请求体规则类型
2026-05-06 22:12:21 +08:00
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace'
interface EditableBodyRule {
action: BodyRuleAction
2026-05-07 00:48:15 +08:00
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
2026-02-05 15:57:52 +08:00
upstreamStreamPolicy: string
rules: EditableRule[]
2026-05-06 01:29:42 +08:00
responseRules: EditableRule[]
bodyRules: EditableBodyRule[]
}
2026-05-06 21:30:13 +08:00
interface EndpointRulesJsonPayload {
header_rules: HeaderRule[]
body_rules: BodyRule[]
response_header_rules: HeaderRule[]
}
2025-12-10 20:52:44 +08:00
const props = defineProps<{
modelValue: boolean
provider: ProviderWithEndpointsSummary | null
endpoints?: ProviderEndpoint[]
systemFormatConversionEnabled?: boolean
providerFormatConversionEnabled?: boolean
2025-12-10 20:52:44 +08:00
}>()
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 ''
})
2025-12-10 20:52:44 +08:00
const { success, error: showError } = useToast()
2026-04-27 20:05:43 +08:00
const proxyNodesStore = useProxyNodesStore()
// 规则 Select 的展开状态(与 Collapsible 分开管理)
const ruleSelectOpen = ref<Record<string, boolean>>({})
2026-05-06 01:29:42 +08:00
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
})
2026-05-06 01:29:42 +08:00
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
}
ruleSelectOpen.value[`${endpointId}-${index}`] = open
}
2026-05-06 01:29:42 +08:00
// 打开响应头规则选择器时关闭其他所有下拉
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
})
2026-05-06 01:29:42 +08:00
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
})
2026-05-06 01:29:42 +08:00
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]
}
})
}
2026-05-06 01:29:42 +08:00
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
}
2026-05-06 01:29:42 +08:00
function isResponseRuleDragging(endpointId: string, index: number): boolean {
return responseRuleDraggedIndex.value[endpointId] === index
}
function isBodyRuleDragOver(endpointId: string, index: number): boolean {
return bodyRuleDragOverIndex.value[endpointId] === index
}
2026-05-06 01:29:42 +08:00
function isResponseRuleDragOver(endpointId: string, index: number): boolean {
return responseRuleDragOverIndex.value[endpointId] === index
}
function clearHeaderRuleDragState(endpointId: string) {
headerRuleDraggedIndex.value[endpointId] = null
headerRuleDragOverIndex.value[endpointId] = null
}
2026-05-06 01:29:42 +08:00
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)
}
2026-05-06 01:29:42 +08:00
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)
2026-04-27 20:05:43 +08:00
const endpointProxyPopoverOpen = ref<Record<string, boolean>>({})
2025-12-10 20:52:44 +08:00
// 删除确认弹窗状态
const deleteConfirmOpen = ref(false)
const endpointToDelete = ref<ProviderEndpoint | null>(null)
// 请求规则折叠状态
const endpointRulesExpanded = ref<Record<string, boolean>>({})
2026-05-06 21:30:13 +08:00
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>>({})
2026-05-06 01:29:42 +08:00
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',
])
2026-05-06 01:29:42 +08:00
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',
])
2026-05-06 22:12:21 +08:00
const BODY_RULE_JSON_ACTIONS = new Set(['set', 'drop', 'rename', 'append', 'insert', 'regex_replace'])
2026-05-06 21:30:13 +08:00
const CONDITION_JSON_OPS = new Set(['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'starts_with', 'ends_with', 'contains', 'matches', 'exists', 'not_exists', 'in', 'type_is'])
2026-05-07 15:56:22 +08:00
const CONDITION_JSON_SOURCES = new Set(['body', 'current', 'original', 'request_headers', 'headers'])
2026-05-06 21:30:13 +08:00
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 无效`
}
2026-05-07 15:56:22 +08:00
if (condition.source !== undefined && (typeof condition.source !== 'string' || !CONDITION_JSON_SOURCES.has(condition.source))) {
return `${label}.source 无效`
2026-05-06 21:30:13 +08:00
}
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} 条必须是对象`
2026-05-07 00:48:15 +08:00
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
}
2026-05-06 21:30:13 +08:00
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} 条必须是对象`
2026-05-07 00:48:15 +08:00
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
}
2026-05-06 21:30:13 +08:00
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)
}
2026-05-06 22:12:21 +08:00
return `${label}第 ${index + 1} 条:action 无效`
2026-05-06 21:30:13 +08:00
}
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) }
}
}
2025-12-10 20:52:44 +08:00
// 内部状态
const internalOpen = computed(() => props.modelValue)
const isFixedProvider = computed(() => {
const t = props.provider?.provider_type
return !!t && t !== 'custom'
})
// 新端点表单
const newEndpoint = ref({
2025-12-10 20:52:44 +08:00
api_format: '',
base_url: '',
custom_path: '',
})
// API 格式列表
const apiFormats = ref<Array<{ value: string; label: string; default_path: string }>>([])
2025-12-10 20:52:44 +08:00
// 本地端点列表
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 getDisplayedPath(endpoint: ProviderEndpoint): string {
if (isFixedProvider.value) {
return getDefaultPath(endpoint.api_format, endpoint.base_url)
}
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
2026-02-05 15:57:52 +08:00
// 读取端点的上游流式策略(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
2026-02-05 15:57:52 +08:00
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'
}
2026-04-27 20:05:43 +08:00
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 {
2026-05-07 00:48:15 +08:00
return { action: 'set', enabled: true, key: '', value: '', from: '', to: '', condition: null }
}
2026-05-06 01:29:42 +08:00
function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): EditableRule[] {
if (!Array.isArray(rules)) return []
const editableRules: EditableRule[] = []
for (const rule of rules) {
if (rule.action === 'set') {
2026-05-07 00:48:15 +08:00
editableRules.push({ ...emptyHeaderRule(), action: 'set', enabled: rule.enabled !== false, key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
2026-05-06 01:29:42 +08:00
} else if (rule.action === 'drop') {
2026-05-07 00:48:15 +08:00
editableRules.push({ ...emptyHeaderRule(), action: 'drop', enabled: rule.enabled !== false, key: rule.key, condition: conditionToEditable(rule.condition) })
2026-05-06 01:29:42 +08:00
} else if (rule.action === 'rename') {
2026-05-07 00:48:15 +08:00
editableRules.push({ ...emptyHeaderRule(), action: 'rename', enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
2026-05-06 01:29:42 +08:00
}
}
return editableRules
}
function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
return {
action,
2026-05-07 00:48:15 +08:00
enabled: true,
path: '',
value: '',
from: '',
to: '',
index: '',
pattern: '',
replacement: '',
flags: '',
count: '',
condition: null,
}
}
2026-05-06 21:30:13 +08:00
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)
2026-05-07 00:48:15 +08:00
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, condition: conditionToEditable(rule.condition) })
2026-05-06 21:30:13 +08:00
} else if (rule.action === 'drop') {
2026-05-07 00:48:15 +08:00
bodyRules.push({ ...emptyBodyRule('drop'), enabled: rule.enabled !== false, path: rule.path, condition: conditionToEditable(rule.condition) })
2026-05-06 21:30:13 +08:00
} else if (rule.action === 'rename') {
2026-05-07 00:48:15 +08:00
bodyRules.push({ ...emptyBodyRule('rename'), enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
2026-05-06 21:30:13 +08:00
} else if (rule.action === 'append') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
2026-05-07 00:48:15 +08:00
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, condition: conditionToEditable(rule.condition) })
2026-05-06 21:30:13 +08:00
} else if (rule.action === 'insert') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
2026-05-07 00:48:15 +08:00
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
2026-05-06 21:30:13 +08:00
} else if (rule.action === 'regex_replace') {
bodyRules.push({
...emptyBodyRule('regex_replace'),
2026-05-07 00:48:15 +08:00
enabled: rule.enabled !== false,
2026-05-06 21:30:13 +08:00
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 {
2026-05-06 01:29:42 +08:00
const rules = editableHeaderRulesFromRules(endpoint.header_rules)
const responseRules = editableHeaderRulesFromRules(getEndpointResponseHeaderRules(endpoint))
2026-05-06 21:30:13 +08:00
const bodyRules = editableBodyRulesFromRules(endpoint.body_rules)
return {
url: endpoint.base_url,
path: endpoint.custom_path || '',
2026-02-05 15:57:52 +08:00
upstreamStreamPolicy: getEndpointUpstreamStreamPolicy(endpoint),
rules,
2026-05-06 01:29:42 +08:00
responseRules,
bodyRules,
}
}
// 获取端点的编辑状态
function getEndpointEditState(endpointId: string): EndpointEditState | undefined {
return endpointEditStates.value[endpointId]
}
2026-05-06 21:30:13 +08:00
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)
}
}
2026-05-06 21:30:13 +08:00
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 []
}
2026-05-06 01:29:42 +08:00
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
}
2026-05-06 01:29:42 +08:00
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)
}
2026-05-06 01:29:42 +08:00
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
2026-05-07 00:48:15 +08:00
const currentEnabled = rules[index].enabled
rules[index] = { ...emptyHeaderRule(), action, enabled: currentEnabled, condition: currentCondition }
}
}
2026-05-06 01:29:42 +08:00
function updateEndpointResponseRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
const currentCondition = rules[index].condition
2026-05-07 00:48:15 +08:00
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
2026-05-06 01:29:42 +08:00
}
}
// 更新规则字段
function updateEndpointRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
2026-05-06 01:29:42 +08:00
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()
}
}
2026-05-06 01:29:42 +08:00
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
}
}
2026-05-06 01:29:42 +08:00
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
}
}
2026-05-06 01:29:42 +08:00
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)
2026-05-07 00:48:15 +08:00
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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)
2026-05-07 00:48:15 +08:00
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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)
2026-05-07 00:48:15 +08:00
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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
}
2026-05-06 01:29:42 +08:00
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
2026-05-07 00:48:15 +08:00
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
}
}
// 更新请求体规则字段
2026-05-06 22:12:21 +08:00
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]
2026-05-07 00:48:15 +08:00
if (currentRule && !currentRule.enabled) return null
// 任意一方启用了条件,则不视为冲突(条件可能互斥,真正冲突在运行时处理)
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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]
2026-05-07 00:48:15 +08:00
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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]
2026-05-07 00:48:15 +08:00
if (currentRule && !currentRule.enabled) return null
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(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
}
2026-05-06 01:29:42 +08:00
// 获取端点的总规则数量(请求头 + 请求体 + 响应头)
function getTotalRulesCount(endpoint: ProviderEndpoint): number {
2026-05-06 01:29:42 +08:00
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
2026-05-07 00:48:15 +08:00
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)
2026-05-07 00:48:15 +08:00
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 }
2026-05-07 00:48:15 +08:00
result.push({ action: 'set', path: rule.path.trim(), value, ...common })
} else if (rule.action === 'drop' && rule.path.trim()) {
2026-05-07 00:48:15 +08:00
result.push({ action: 'drop', path: rule.path.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
2026-05-07 00:48:15 +08:00
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 }
2026-05-07 00:48:15 +08:00
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)
2026-05-07 00:48:15 +08:00
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) } : {}),
}
2026-05-07 00:48:15 +08:00
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]
2026-05-07 00:48:15 +08:00
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
}
// 检查端点规则是否有修改
2026-05-06 01:29:42 +08:00
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
2026-05-07 00:48:15 +08:00
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
}
2026-05-06 01:29:42 +08:00
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))
}
2026-05-06 21:30:13 +08:00
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 {
2026-05-06 21:30:13 +08:00
return hasUrlChanges(endpoint) || hasRulePanelChanges(endpoint)
}
// 重置端点修改
function resetEndpointChanges(endpoint: ProviderEndpoint) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
2026-05-06 21:30:13 +08:00
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
2026-05-06 01:29:42 +08:00
endpointRulesExpanded.value[endpoint.id] = (state.rules.length + state.responseRules.length + state.bodyRules.length) > 0
clearBodyRuleDragState(endpoint.id)
clearBodyRuleSelectOpen(endpoint.id)
2026-05-06 21:30:13 +08:00
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)
2026-05-07 00:48:15 +08:00
const common = { ...(rule.enabled ? {} : { enabled: false }), ...(condition ? { condition } : {}) }
if (rule.action === 'set' && rule.key.trim()) {
2026-05-07 00:48:15 +08:00
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...common })
} else if (rule.action === 'drop' && rule.key.trim()) {
2026-05-07 00:48:15 +08:00
result.push({ action: 'drop', key: rule.key.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
2026-05-07 00:48:15 +08:00
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim(), ...common })
}
}
return result.length > 0 ? result : null
}
2026-05-06 01:29:42 +08:00
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]
2026-05-07 00:48:15 +08:00
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
}
2026-05-06 01:29:42 +08:00
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)
2026-05-07 00:48:15 +08:00
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
2026-05-06 01:29:42 +08:00
const duplicate = rules.findIndex(
2026-05-07 00:48:15 +08:00
(r, i) => i !== index && r.enabled && (
2026-05-06 01:29:42 +08:00
((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]
2026-05-07 00:48:15 +08:00
if (!rule.enabled) continue
2026-05-06 01:29:42 +08:00
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 来判断是否是 Codex 端点
const baseUrl = newEndpoint.value.base_url || props.provider?.website || ''
return getDefaultPath(newEndpoint.value.api_format, baseUrl)
})
// 加载 API 格式列表
2025-12-10 20:52:44 +08:00
const loadApiFormats = async () => {
try {
const response = await adminApi.getApiFormats()
apiFormats.value = response.formats
} catch (error) {
log.error('加载API格式失败:', error)
2025-12-10 20:52:44 +08:00
}
}
onMounted(() => {
loadApiFormats()
})
// 监听 props 变化
watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
2026-05-06 01:29:42 +08:00
responseRuleSelectOpen.value = {}
bodyRuleSelectOpen.value = {}
headerRuleDraggedIndex.value = {}
headerRuleDragOverIndex.value = {}
2026-05-06 01:29:42 +08:00
responseRuleDraggedIndex.value = {}
responseRuleDragOverIndex.value = {}
bodyRuleDraggedIndex.value = {}
bodyRuleDragOverIndex.value = {}
2026-05-06 21:30:13 +08:00
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)
// 有规则时默认展开
2026-05-06 01:29:42 +08:00
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: '' }
2025-12-10 20:52:44 +08:00
}
}, { 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)
}
2026-05-06 21:30:13 +08:00
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) {
2026-05-06 21:30:13 +08:00
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
2025-12-10 20:52:44 +08:00
// 检查规则是否有验证错误
const headerErr = getHeaderValidationErrorForEndpoint(endpoint.id)
if (headerErr) {
showError(headerErr)
return
}
2026-05-06 01:29:42 +08:00
const responseHeaderErr = getResponseHeaderValidationErrorForEndpoint(endpoint.id)
if (responseHeaderErr) {
showError(responseHeaderErr)
return
}
// 检查请求体规则是否有验证错误
const bodyErr = getBodyValidationErrorForEndpoint(endpoint.id)
if (bodyErr) {
showError(bodyErr)
return
}
savingEndpointId.value = endpoint.id
2025-12-10 20:52:44 +08:00
try {
2026-02-05 15:57:52 +08:00
// 仅提交变更字段,避免 fixed provider 因 base_url/custom_path 被锁定而更新失败
const payload: Record<string, unknown> = {}
2026-02-05 15:57:52 +08:00
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)
2026-05-06 01:29:42 +08:00
if (hasResponseHeaderRulesChanges(endpoint)) {
payload.config = endpointConfigWithResponseHeaderRules(
endpoint,
rulesToHeaderRules(state.responseRules),
)
}
2026-02-05 15:57:52 +08:00
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
// 注:upstreamStreamPolicy 现在由头部按钮直接保存,不在此处处理
2026-02-05 15:57:52 +08:00
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
success('端点已更新')
emit('endpointUpdated')
} catch (error: unknown) {
showError(parseApiError(error, '更新失败'), '错误')
2025-12-10 20:52:44 +08:00
} finally {
savingEndpointId.value = null
2025-12-10 20:52:44 +08:00
}
}
// 切换格式转换(直接保存)
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 = newEndpoint.value.base_url || props.provider.website
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)
}
2025-12-10 20:52:44 +08:00
</script>