mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
feat: 添加请求体规则功能和 UI 优化
- 添加 body_rules 支持(set/drop/rename 操作) - 后端 apply_body_rules 函数处理请求体修改 - 保护 model/stream 字段不被修改 - 前端 UI 重构:合并请求头/请求体规则为统一区域 - 使用左边框颜色区分规则类型(H=主题色,B=灰色) Closes #139 Co-authored-by: CC <81609553+hemo94931@users.noreply.github.com>
This commit is contained in:
@@ -1,43 +0,0 @@
|
|||||||
"""add video_duration_seconds to video_tasks
|
|
||||||
|
|
||||||
Revision ID: b3c4d5e6f7a8
|
|
||||||
Revises: a2f1b3c4d5e6
|
|
||||||
Create Date: 2026-02-03 15:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "b3c4d5e6f7a8"
|
|
||||||
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
|
||||||
"""Check if a column exists in a table."""
|
|
||||||
bind = op.get_bind()
|
|
||||||
inspector = inspect(bind)
|
|
||||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
|
||||||
return column_name in columns
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Add video_duration_seconds column to video_tasks table."""
|
|
||||||
if not _column_exists("video_tasks", "video_duration_seconds"):
|
|
||||||
op.add_column(
|
|
||||||
"video_tasks",
|
|
||||||
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Remove video_duration_seconds column from video_tasks table."""
|
|
||||||
if _column_exists("video_tasks", "video_duration_seconds"):
|
|
||||||
op.drop_column("video_tasks", "video_duration_seconds")
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import type { ProviderEndpoint, ProxyConfig, HeaderRule, FormatAcceptanceConfig } from './types'
|
import type { ProviderEndpoint, ProxyConfig, HeaderRule, BodyRule, FormatAcceptanceConfig } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定 Provider 的所有 Endpoints
|
* 获取指定 Provider 的所有 Endpoints
|
||||||
@@ -28,6 +28,7 @@ export async function createEndpoint(
|
|||||||
base_url: string
|
base_url: string
|
||||||
custom_path?: string
|
custom_path?: string
|
||||||
header_rules?: HeaderRule[]
|
header_rules?: HeaderRule[]
|
||||||
|
body_rules?: BodyRule[]
|
||||||
max_retries?: number
|
max_retries?: number
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
@@ -48,6 +49,7 @@ export async function updateEndpoint(
|
|||||||
base_url: string
|
base_url: string
|
||||||
custom_path: string | null
|
custom_path: string | null
|
||||||
header_rules: HeaderRule[] | null
|
header_rules: HeaderRule[] | null
|
||||||
|
body_rules: BodyRule[] | null
|
||||||
max_retries: number
|
max_retries: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config: Record<string, any>
|
config: Record<string, any>
|
||||||
|
|||||||
@@ -114,6 +114,31 @@ export interface HeaderRuleRename {
|
|||||||
|
|
||||||
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体规则类型
|
||||||
|
* - set: 设置/覆盖字段
|
||||||
|
* - drop: 删除字段
|
||||||
|
* - rename: 重命名字段(保留原值)
|
||||||
|
*/
|
||||||
|
export interface BodyRuleSet {
|
||||||
|
action: 'set'
|
||||||
|
path: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleDrop {
|
||||||
|
action: 'drop'
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleRename {
|
||||||
|
action: 'rename'
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式接受策略配置
|
* 格式接受策略配置
|
||||||
* 用于控制端点是否接受来自不同 API 格式的请求,并自动进行格式转换
|
* 用于控制端点是否接受来自不同 API 格式的请求,并自动进行格式转换
|
||||||
@@ -133,6 +158,8 @@ export interface ProviderEndpoint {
|
|||||||
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
||||||
// 请求头配置
|
// 请求头配置
|
||||||
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
|
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
|
||||||
|
// 请求体配置
|
||||||
|
body_rules?: BodyRule[] // 请求体规则列表,支持 set/drop/rename 操作
|
||||||
max_retries: number
|
max_retries: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
|
|||||||
@@ -73,3 +73,6 @@ export { default as CollapsibleContent } from './collapsible-content.vue'
|
|||||||
|
|
||||||
// DropdownMenu 下拉菜单系列
|
// DropdownMenu 下拉菜单系列
|
||||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './dropdown-menu'
|
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './dropdown-menu'
|
||||||
|
|
||||||
|
// Popover 弹出框系列
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent } from './popover'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<!-- 已有端点列表(可滚动) -->
|
<!-- 已有端点列表(可滚动) -->
|
||||||
<div
|
<div
|
||||||
v-if="localEndpoints.length > 0"
|
v-if="localEndpoints.length > 0"
|
||||||
class="space-y-3 max-h-[50vh] overflow-y-auto"
|
class="space-y-3 max-h-[50vh] overflow-y-auto scrollbar-hide"
|
||||||
>
|
>
|
||||||
<Label class="text-muted-foreground">已配置的端点</Label>
|
<Label class="text-muted-foreground">已配置的端点</Label>
|
||||||
|
|
||||||
@@ -122,172 +122,245 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 请求头规则 -->
|
<!-- 请求规则(合并请求头和请求体规则) -->
|
||||||
<div
|
<Collapsible v-model:open="endpointRulesExpanded[endpoint.id]">
|
||||||
v-if="hasAnyRules(endpoint)"
|
<div class="flex items-center gap-2">
|
||||||
class="space-y-2"
|
<!-- 有规则时显示可折叠的触发器 -->
|
||||||
>
|
<CollapsibleTrigger
|
||||||
<Collapsible v-model:open="endpointRulesExpanded[endpoint.id]">
|
v-if="getTotalRulesCount(endpoint) > 0"
|
||||||
<div class="flex items-center gap-2">
|
as-child
|
||||||
<CollapsibleTrigger as-child>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 flex-1 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors"
|
class="flex items-center gap-2 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
class="w-4 h-4 transition-transform text-muted-foreground"
|
||||||
|
:class="{ 'rotate-90': endpointRulesExpanded[endpoint.id] }"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">请求规则</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
{{ getTotalRulesCount(endpoint) }} 条
|
||||||
class="w-4 h-4 transition-transform text-muted-foreground"
|
</Badge>
|
||||||
:class="{ 'rotate-90': endpointRulesExpanded[endpoint.id] }"
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<!-- 没有规则时只显示标题 -->
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-muted-foreground py-1.5"
|
||||||
|
>
|
||||||
|
请求规则
|
||||||
|
</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
v-if="hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint)"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="保存规则"
|
||||||
|
:disabled="savingEndpointId === endpoint.id"
|
||||||
|
@click="saveEndpoint(endpoint)"
|
||||||
|
>
|
||||||
|
<Check class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent class="pt-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- 请求头规则列表 - 主题色边框 -->
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
|
||||||
|
:key="`header-${index}`"
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-semibold text-primary shrink-0"
|
||||||
|
title="请求头"
|
||||||
|
>H</span>
|
||||||
|
<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>
|
||||||
|
<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-sm font-medium">请求头规则</span>
|
<span class="text-muted-foreground text-xs">=</span>
|
||||||
<Badge
|
<Input
|
||||||
v-if="getEndpointRulesCount(endpoint) > 0"
|
:model-value="rule.value"
|
||||||
variant="secondary"
|
placeholder="值"
|
||||||
class="text-xs"
|
size="sm"
|
||||||
>
|
class="flex-1 min-w-0 h-7 text-xs"
|
||||||
{{ getEndpointRulesCount(endpoint) }} 条
|
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
||||||
</Badge>
|
/>
|
||||||
</button>
|
</template>
|
||||||
</CollapsibleTrigger>
|
<template v-else-if="rule.action === 'drop'">
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<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
|
<Button
|
||||||
v-if="hasRulesChanges(endpoint)"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7 shrink-0"
|
||||||
title="保存"
|
@click="removeEndpointRule(endpoint.id, index)"
|
||||||
:disabled="savingEndpointId === endpoint.id"
|
|
||||||
@click="saveEndpoint(endpoint)"
|
|
||||||
>
|
>
|
||||||
<Check class="w-3.5 h-3.5" />
|
<X class="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求体规则列表 - 次要色边框 -->
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in getEndpointEditBodyRules(endpoint.id)"
|
||||||
|
:key="`body-${index}`"
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-semibold text-muted-foreground shrink-0"
|
||||||
|
title="请求体"
|
||||||
|
>B</span>
|
||||||
|
<Select
|
||||||
|
:model-value="rule.action"
|
||||||
|
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
|
||||||
|
@update:model-value="(v) => updateEndpointBodyRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
||||||
|
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="set">
|
||||||
|
覆写
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="drop">
|
||||||
|
删除
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rename">
|
||||||
|
重命名
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<template v-if="rule.action === 'set'">
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
<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) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7 shrink-0"
|
||||||
title="添加规则"
|
@click="removeEndpointBodyRule(endpoint.id, index)"
|
||||||
@click="handleAddEndpointRule(endpoint.id)"
|
|
||||||
>
|
>
|
||||||
<Plus class="w-3.5 h-3.5" />
|
<X class="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleContent class="pt-2 pl-6">
|
</CollapsibleContent>
|
||||||
<div class="space-y-2">
|
</Collapsible>
|
||||||
<div
|
|
||||||
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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-24 h-8 text-xs shrink-0">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
position="popper"
|
|
||||||
:side-offset="4"
|
|
||||||
>
|
|
||||||
<SelectItem value="set">
|
|
||||||
覆写
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="drop">
|
|
||||||
删除
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="rename">
|
|
||||||
重命名
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<template v-if="rule.action === 'set'">
|
|
||||||
<Input
|
|
||||||
:model-value="rule.key"
|
|
||||||
placeholder="Header 名称"
|
|
||||||
size="sm"
|
|
||||||
:class="`flex-1 min-w-0 text-sm ${validateRuleKeyForEndpoint(endpoint.id, rule.key, index) ? 'border-destructive' : ''}`"
|
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground">=</span>
|
|
||||||
<Input
|
|
||||||
:model-value="rule.value"
|
|
||||||
placeholder="Header 值"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1 min-w-0 text-sm"
|
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="rule.action === 'drop'">
|
|
||||||
<Input
|
|
||||||
:model-value="rule.key"
|
|
||||||
placeholder="要删除的 Header 名称"
|
|
||||||
size="sm"
|
|
||||||
:class="`flex-1 min-w-0 text-sm ${validateRuleKeyForEndpoint(endpoint.id, rule.key, index) ? 'border-destructive' : ''}`"
|
|
||||||
@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 text-sm ${validateRenameFromForEndpoint(endpoint.id, rule.from, index) ? 'border-destructive' : ''}`"
|
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
|
|
||||||
/>
|
|
||||||
<ArrowRight class="w-4 h-4 shrink-0 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
:model-value="rule.to"
|
|
||||||
placeholder="新名称"
|
|
||||||
size="sm"
|
|
||||||
:class="`flex-1 min-w-0 text-sm ${validateRenameToForEndpoint(endpoint.id, rule.to, index) ? 'border-destructive' : ''}`"
|
|
||||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0"
|
|
||||||
@click="removeEndpointRule(endpoint.id, index)"
|
|
||||||
>
|
|
||||||
<X class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 没有请求头规则时只显示添加按钮 -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-muted-foreground">请求头规则</span>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<Button
|
|
||||||
v-if="hasRulesChanges(endpoint)"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7"
|
|
||||||
title="保存"
|
|
||||||
:disabled="savingEndpointId === endpoint.id"
|
|
||||||
@click="saveEndpoint(endpoint)"
|
|
||||||
>
|
|
||||||
<Check class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7"
|
|
||||||
title="添加规则"
|
|
||||||
@click="handleAddEndpointRule(endpoint.id)"
|
|
||||||
>
|
|
||||||
<Plus class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +470,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, ArrowRight, Shuffle, RotateCcw } from 'lucide-vue-next'
|
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
@@ -409,6 +482,7 @@ import {
|
|||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type ProviderWithEndpointsSummary,
|
type ProviderWithEndpointsSummary,
|
||||||
type HeaderRule,
|
type HeaderRule,
|
||||||
|
type BodyRule,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
@@ -421,11 +495,21 @@ interface EditableRule {
|
|||||||
to: string // rename 用
|
to: string // rename 用
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编辑用的请求体规则类型
|
||||||
|
interface EditableBodyRule {
|
||||||
|
action: 'set' | 'drop' | 'rename'
|
||||||
|
path: string // set/drop 用
|
||||||
|
value: string // set 用
|
||||||
|
from: string // rename 用
|
||||||
|
to: string // rename 用
|
||||||
|
}
|
||||||
|
|
||||||
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
||||||
interface EndpointEditState {
|
interface EndpointEditState {
|
||||||
url: string
|
url: string
|
||||||
path: string
|
path: string
|
||||||
rules: EditableRule[]
|
rules: EditableRule[]
|
||||||
|
bodyRules: EditableBodyRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -464,10 +548,28 @@ function handleFormatSelectOpen(open: boolean) {
|
|||||||
Object.keys(ruleSelectOpen.value).forEach(key => {
|
Object.keys(ruleSelectOpen.value).forEach(key => {
|
||||||
ruleSelectOpen.value[key] = false
|
ruleSelectOpen.value[key] = false
|
||||||
})
|
})
|
||||||
|
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
|
||||||
|
bodyRuleSelectOpen.value[key] = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
formatSelectOpen.value = open
|
formatSelectOpen.value = open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开请求体规则选择器时关闭其他所有下拉
|
||||||
|
function handleBodyRuleSelectOpen(endpointId: string, index: number, open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
formatSelectOpen.value = false
|
||||||
|
// 关闭所有 Select
|
||||||
|
Object.keys(ruleSelectOpen.value).forEach(key => {
|
||||||
|
ruleSelectOpen.value[key] = false
|
||||||
|
})
|
||||||
|
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
|
||||||
|
bodyRuleSelectOpen.value[key] = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyRuleSelectOpen.value[`${endpointId}-${index}`] = open
|
||||||
|
}
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const addingEndpoint = ref(false)
|
const addingEndpoint = ref(false)
|
||||||
const savingEndpointId = ref<string | null>(null)
|
const savingEndpointId = ref<string | null>(null)
|
||||||
@@ -480,9 +582,12 @@ const formatSelectOpen = ref(false)
|
|||||||
const deleteConfirmOpen = ref(false)
|
const deleteConfirmOpen = ref(false)
|
||||||
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
||||||
|
|
||||||
// 请求头规则折叠状态
|
// 请求规则折叠状态
|
||||||
const endpointRulesExpanded = ref<Record<string, boolean>>({})
|
const endpointRulesExpanded = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// 请求体规则 Select 的展开状态
|
||||||
|
const bodyRuleSelectOpen = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
// 每个端点的编辑状态(内联编辑)
|
// 每个端点的编辑状态(内联编辑)
|
||||||
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
|
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
|
||||||
|
|
||||||
@@ -496,6 +601,12 @@ const RESERVED_HEADERS = new Set([
|
|||||||
'host',
|
'host',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 系统保留的 body 字段名(不允许用户设置)
|
||||||
|
const RESERVED_BODY_FIELDS = new Set([
|
||||||
|
'model',
|
||||||
|
'stream',
|
||||||
|
])
|
||||||
|
|
||||||
// 内部状态
|
// 内部状态
|
||||||
const internalOpen = computed(() => props.modelValue)
|
const internalOpen = computed(() => props.modelValue)
|
||||||
|
|
||||||
@@ -546,10 +657,24 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyRules: EditableBodyRule[] = []
|
||||||
|
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||||
|
for (const rule of endpoint.body_rules) {
|
||||||
|
if (rule.action === 'set') {
|
||||||
|
bodyRules.push({ action: 'set', path: rule.path, value: rule.value || '', from: '', to: '' })
|
||||||
|
} else if (rule.action === 'drop') {
|
||||||
|
bodyRules.push({ action: 'drop', path: rule.path, value: '', from: '', to: '' })
|
||||||
|
} else if (rule.action === 'rename') {
|
||||||
|
bodyRules.push({ action: 'rename', path: '', value: '', from: rule.from, to: rule.to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: endpoint.base_url,
|
url: endpoint.base_url,
|
||||||
path: endpoint.custom_path || '',
|
path: endpoint.custom_path || '',
|
||||||
rules,
|
rules,
|
||||||
|
bodyRules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +832,239 @@ function hasAnyRules(endpoint: ProviderEndpoint): boolean {
|
|||||||
return (endpoint.header_rules?.length || 0) > 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({ action: 'set', path: '', value: '', from: '', to: '' })
|
||||||
|
// 自动展开折叠
|
||||||
|
endpointRulesExpanded.value[endpointId] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除请求体规则
|
||||||
|
function removeEndpointBodyRule(endpointId: string, index: number) {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
rules.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新请求体规则类型
|
||||||
|
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index].action = action
|
||||||
|
rules[index].path = ''
|
||||||
|
rules[index].value = ''
|
||||||
|
rules[index].from = ''
|
||||||
|
rules[index].to = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新请求体规则字段
|
||||||
|
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to', value: string) {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
if (rules[index]) {
|
||||||
|
rules[index][field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求体规则 path(针对特定端点)
|
||||||
|
function validateBodyRulePathForEndpoint(endpointId: string, path: string, index: number): string | null {
|
||||||
|
const trimmedPath = path.trim().toLowerCase()
|
||||||
|
if (!trimmedPath) return null
|
||||||
|
|
||||||
|
if (RESERVED_BODY_FIELDS.has(trimmedPath)) {
|
||||||
|
return `"${path}" 是系统保留的字段`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
const duplicate = rules.findIndex(
|
||||||
|
(r, i) => i !== index && (
|
||||||
|
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === trimmedPath) ||
|
||||||
|
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedPath)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (duplicate >= 0) {
|
||||||
|
return '字段名重复'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求体 rename from
|
||||||
|
function validateBodyRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
|
||||||
|
const trimmedFrom = from.trim().toLowerCase()
|
||||||
|
if (!trimmedFrom) return null
|
||||||
|
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
const duplicate = rules.findIndex(
|
||||||
|
(r, i) => i !== index &&
|
||||||
|
((r.action === 'set' && r.path.trim().toLowerCase() === trimmedFrom) ||
|
||||||
|
(r.action === 'drop' && r.path.trim().toLowerCase() === trimmedFrom) ||
|
||||||
|
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
|
||||||
|
)
|
||||||
|
if (duplicate >= 0) {
|
||||||
|
return '该字段已被其他规则处理'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求体 rename to
|
||||||
|
function validateBodyRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
|
||||||
|
const trimmedTo = to.trim().toLowerCase()
|
||||||
|
if (!trimmedTo) return null
|
||||||
|
|
||||||
|
if (RESERVED_BODY_FIELDS.has(trimmedTo)) {
|
||||||
|
return `"${to}" 是系统保留的字段`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
const duplicate = rules.findIndex(
|
||||||
|
(r, i) => i !== index &&
|
||||||
|
((r.action === 'set' && r.path.trim().toLowerCase() === trimmedTo) ||
|
||||||
|
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
|
||||||
|
)
|
||||||
|
if (duplicate >= 0) {
|
||||||
|
return '字段名重复'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取端点的请求体规则数量(有效的规则)
|
||||||
|
function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
|
||||||
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
|
if (state) {
|
||||||
|
return state.bodyRules.filter(r => {
|
||||||
|
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||||
|
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||||
|
return false
|
||||||
|
}).length
|
||||||
|
}
|
||||||
|
return endpoint.body_rules?.length || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查端点是否有任何请求体规则(包括正在编辑的空规则)
|
||||||
|
function hasAnyBodyRules(endpoint: ProviderEndpoint): boolean {
|
||||||
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
|
if (state) {
|
||||||
|
return state.bodyRules.length > 0
|
||||||
|
}
|
||||||
|
return (endpoint.body_rules?.length || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取端点的总规则数量(请求头 + 请求体)
|
||||||
|
function getTotalRulesCount(endpoint: ProviderEndpoint): number {
|
||||||
|
return getEndpointRulesCount(endpoint) + getEndpointBodyRulesCount(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化请求头规则的显示标签
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
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(r => {
|
||||||
|
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||||
|
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (editedRules.length !== originalRules.length) return true
|
||||||
|
for (let i = 0; i < editedRules.length; i++) {
|
||||||
|
const edited = editedRules[i]
|
||||||
|
const original = originalRules[i]
|
||||||
|
if (!original) return true
|
||||||
|
if (edited.action !== original.action) return true
|
||||||
|
if (edited.action === 'set' && original.action === 'set') {
|
||||||
|
if (edited.path !== original.path || edited.value !== (original.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将可编辑请求体规则数组转换为 API 需要的 BodyRule[]
|
||||||
|
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||||
|
const result: BodyRule[] = []
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.action === 'set' && rule.path.trim()) {
|
||||||
|
result.push({ action: 'set', path: rule.path.trim(), value: rule.value })
|
||||||
|
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||||
|
result.push({ action: 'drop', path: rule.path.trim() })
|
||||||
|
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||||
|
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.length > 0 ? result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查请求体规则是否有验证错误
|
||||||
|
function hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
|
||||||
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i]
|
||||||
|
if (rule.action === 'set' || rule.action === 'drop') {
|
||||||
|
if (validateBodyRulePathForEndpoint(endpointId, rule.path, i)) return true
|
||||||
|
} else if (rule.action === 'rename') {
|
||||||
|
if (validateBodyRenameFromForEndpoint(endpointId, rule.from, i)) return true
|
||||||
|
if (validateBodyRenameToForEndpoint(endpointId, rule.to, i)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 检查端点 URL/路径是否有修改
|
// 检查端点 URL/路径是否有修改
|
||||||
function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
|
function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
|
||||||
const state = endpointEditStates.value[endpoint.id]
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
@@ -745,8 +1103,9 @@ function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查端点是否有修改(URL、路径或规则)
|
// 检查端点是否有修改(URL、路径或规则)
|
||||||
function hasEndpointChanges(endpoint: ProviderEndpoint): boolean {
|
// 注:当前模板直接使用各子函数,此聚合函数保留供未来使用
|
||||||
return hasUrlChanges(endpoint) || hasRulesChanges(endpoint)
|
function _hasEndpointChanges(endpoint: ProviderEndpoint): boolean {
|
||||||
|
return hasUrlChanges(endpoint) || hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置端点修改
|
// 重置端点修改
|
||||||
@@ -812,9 +1171,12 @@ watch(() => props.modelValue, (open) => {
|
|||||||
// 清空编辑状态,重新从端点加载
|
// 清空编辑状态,重新从端点加载
|
||||||
endpointEditStates.value = {}
|
endpointEditStates.value = {}
|
||||||
endpointRulesExpanded.value = {}
|
endpointRulesExpanded.value = {}
|
||||||
// 初始化每个端点的编辑状态
|
// 初始化每个端点的编辑状态,有规则时默认展开
|
||||||
for (const endpoint of localEndpoints.value) {
|
for (const endpoint of localEndpoints.value) {
|
||||||
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
||||||
|
// 有规则时默认展开
|
||||||
|
const hasRules = (endpoint.header_rules?.length || 0) + (endpoint.body_rules?.length || 0) > 0
|
||||||
|
endpointRulesExpanded.value[endpoint.id] = hasRules
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 关闭对话框时完全清空新端点表单
|
// 关闭对话框时完全清空新端点表单
|
||||||
@@ -845,12 +1207,19 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查请求体规则是否有验证错误
|
||||||
|
if (hasBodyValidationErrorsForEndpoint(endpoint.id)) {
|
||||||
|
showError('请修正请求体规则中的错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savingEndpointId.value = endpoint.id
|
savingEndpointId.value = endpoint.id
|
||||||
try {
|
try {
|
||||||
await updateEndpoint(endpoint.id, {
|
await updateEndpoint(endpoint.id, {
|
||||||
base_url: state.url,
|
base_url: state.url,
|
||||||
custom_path: state.path || null,
|
custom_path: state.path || null,
|
||||||
header_rules: rulesToHeaderRules(state.rules),
|
header_rules: rulesToHeaderRules(state.rules),
|
||||||
|
body_rules: rulesToBodyRules(state.bodyRules),
|
||||||
})
|
})
|
||||||
success('端点已更新')
|
success('端点已更新')
|
||||||
emit('endpointUpdated')
|
emit('endpointUpdated')
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ class ProviderAuthInfo:
|
|||||||
# 兼容别名:历史代码使用 SENSITIVE_HEADERS 命名
|
# 兼容别名:历史代码使用 SENSITIVE_HEADERS 命名
|
||||||
SENSITIVE_HEADERS: frozenset[str] = UPSTREAM_DROP_HEADERS
|
SENSITIVE_HEADERS: frozenset[str] = UPSTREAM_DROP_HEADERS
|
||||||
|
|
||||||
|
# 请求体中受保护的字段(不能被 body_rules 修改)
|
||||||
|
PROTECTED_BODY_FIELDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"model", # 模型名由系统管理
|
||||||
|
"stream", # 流式标志由系统管理
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 测试请求常量与辅助函数
|
# 测试请求常量与辅助函数
|
||||||
@@ -126,6 +134,65 @@ def build_test_request_body(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 请求体规则应用
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def apply_body_rules(
|
||||||
|
body: dict[str, Any],
|
||||||
|
rules: list[dict[str, Any]],
|
||||||
|
protected_keys: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
应用请求体规则
|
||||||
|
|
||||||
|
支持的规则类型:
|
||||||
|
- set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
|
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
|
- rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: 原始请求体
|
||||||
|
rules: 规则列表
|
||||||
|
protected_keys: 受保护的字段(不能被 set/drop/rename 修改)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
应用规则后的请求体
|
||||||
|
"""
|
||||||
|
if not rules:
|
||||||
|
return body
|
||||||
|
|
||||||
|
# 复制一份,避免修改原始数据
|
||||||
|
result = dict(body)
|
||||||
|
protected = protected_keys or PROTECTED_BODY_FIELDS
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
action = rule.get("action")
|
||||||
|
|
||||||
|
if action == "set":
|
||||||
|
path = rule.get("path", "")
|
||||||
|
value = rule.get("value")
|
||||||
|
if path and path not in protected:
|
||||||
|
result[path] = value
|
||||||
|
|
||||||
|
elif action == "drop":
|
||||||
|
path = rule.get("path", "")
|
||||||
|
if path and path not in protected:
|
||||||
|
result.pop(path, None)
|
||||||
|
|
||||||
|
elif action == "rename":
|
||||||
|
from_key = rule.get("from", "")
|
||||||
|
to_key = rule.get("to", "")
|
||||||
|
if from_key and to_key:
|
||||||
|
# 两个 key 都不能是受保护的
|
||||||
|
if from_key not in protected and to_key not in protected:
|
||||||
|
if from_key in result:
|
||||||
|
result[to_key] = result.pop(from_key)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 请求构建器
|
# 请求构建器
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -191,6 +258,12 @@ class RequestBuilder(ABC):
|
|||||||
mapped_model=mapped_model,
|
mapped_model=mapped_model,
|
||||||
is_stream=is_stream,
|
is_stream=is_stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 应用请求体规则(如果 endpoint 配置了 body_rules)
|
||||||
|
body_rules = getattr(endpoint, "body_rules", None)
|
||||||
|
if body_rules:
|
||||||
|
payload = apply_body_rules(payload, body_rules)
|
||||||
|
|
||||||
headers = self.build_headers(
|
headers = self.build_headers(
|
||||||
original_headers,
|
original_headers,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|||||||
@@ -739,6 +739,7 @@ class ProviderEndpoint(Base):
|
|||||||
|
|
||||||
# 请求配置
|
# 请求配置
|
||||||
header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
|
header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
|
||||||
|
body_rules = Column(JSON, nullable=True) # 请求体规则 [{action, path, value, from, to}]
|
||||||
max_retries = Column(Integer, default=2) # 最大重试次数
|
max_retries = Column(Integer, default=2) # 最大重试次数
|
||||||
|
|
||||||
# 状态
|
# 状态
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ from src.models.admin_requests import ProxyConfig
|
|||||||
HeaderRule = dict[str, Any]
|
HeaderRule = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Body Rule 类型定义 ==========
|
||||||
|
# 请求体规则支持三种操作:
|
||||||
|
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
|
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
|
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
|
# 实际验证在 request_builder.py 的 apply_body_rules 中处理
|
||||||
|
BodyRule = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
# ========== ProviderEndpoint CRUD ==========
|
# ========== ProviderEndpoint CRUD ==========
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +52,12 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
description="请求头规则列表,支持 set/drop/rename 操作",
|
description="请求头规则列表,支持 set/drop/rename 操作",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 请求体配置
|
||||||
|
body_rules: list[BodyRule] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="请求体规则列表,支持 set/drop/rename 操作",
|
||||||
|
)
|
||||||
|
|
||||||
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
|
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
|
||||||
|
|
||||||
# 额外配置
|
# 额外配置
|
||||||
@@ -93,6 +108,12 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
description="请求头规则列表,支持 set/drop/rename 操作",
|
description="请求头规则列表,支持 set/drop/rename 操作",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 请求体配置
|
||||||
|
body_rules: list[BodyRule] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="请求体规则列表,支持 set/drop/rename 操作",
|
||||||
|
)
|
||||||
|
|
||||||
max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数")
|
max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数")
|
||||||
is_active: bool | None = Field(default=None, description="是否启用")
|
is_active: bool | None = Field(default=None, description="是否启用")
|
||||||
config: dict[str, Any] | None = Field(default=None, description="额外配置")
|
config: dict[str, Any] | None = Field(default=None, description="额外配置")
|
||||||
@@ -132,6 +153,11 @@ class ProviderEndpointResponse(BaseModel):
|
|||||||
# 请求头配置
|
# 请求头配置
|
||||||
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
|
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
|
||||||
|
|
||||||
|
# 请求体配置
|
||||||
|
body_rules: list[BodyRule] | None = Field(
|
||||||
|
default=None, description="请求体规则列表"
|
||||||
|
)
|
||||||
|
|
||||||
max_retries: int
|
max_retries: int
|
||||||
|
|
||||||
# 状态
|
# 状态
|
||||||
|
|||||||
Reference in New Issue
Block a user