mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 请求体规则扩展(append/insert/regex_replace)、OAuth 代理节点支持与列表分页
- 请求体规则新增 append、insert、regex_replace 三种操作,路径语法支持数组索引 - OAuth 授权/导入/批量导入支持指定代理节点(proxy_node_id),Key 级代理避免 IP 污染 - 密钥列表、模型映射、模型列表添加智能分页(useSmartPagination) - AdvancedGuide 新增请求体规则使用指南与示例 - 简化 Codex enrich_codex 实现,README 添加 QQ 群二维码
This commit is contained in:
@@ -198,6 +198,8 @@ docker compose up -d app
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
||||||
|
|
||||||
|
<img src="docs/author/qrcode_1770574997172.jpg" width="200" alt="QQ群二维码">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|||||||
BIN
docs/author/qrcode_1770574997172.jpg
Normal file
BIN
docs/author/qrcode_1770574997172.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
262
docs/guides/body-rules-spec.md
Normal file
262
docs/guides/body-rules-spec.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Aether 请求体规则 (Body Rules) — AI 辅助指南
|
||||||
|
|
||||||
|
> 本文档面向 AI 助手。用户在「端点管理 → 请求规则 → + 请求体」中添加规则,AI 需要指导用户在 UI 表单的各个输入框中填写什么内容。
|
||||||
|
|
||||||
|
## 系统背景
|
||||||
|
|
||||||
|
Aether 是一个 AI API 网关,将用户请求转发给上游 Provider(OpenAI、Claude、Gemini 等)。请求体规则在转发前修改请求体 JSON,每个 Endpoint 可配置多条规则。
|
||||||
|
|
||||||
|
## 用户请求体长什么样
|
||||||
|
|
||||||
|
规则操作的对象是用户发给 Aether 的 JSON 请求体。根据 Endpoint 的 API 格式不同,结构也不同:
|
||||||
|
|
||||||
|
### OpenAI Chat 格式(`openai:chat`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gpt-4",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你是一个助手"},
|
||||||
|
{"role": "user", "content": "你好"}
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"stream": true,
|
||||||
|
"top_p": 1,
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"stop": ["\n"],
|
||||||
|
"tools": [...],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"metadata": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Chat 格式(`claude:chat`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "你好"}
|
||||||
|
],
|
||||||
|
"system": "你是一个助手",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"stream": true,
|
||||||
|
"top_p": 1,
|
||||||
|
"top_k": 40,
|
||||||
|
"stop_sequences": ["###"],
|
||||||
|
"metadata": {"user_id": "xxx"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude CLI 格式(`claude:cli`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "帮我重构这段代码"}
|
||||||
|
],
|
||||||
|
"system": "你是一个编程助手",
|
||||||
|
"max_tokens": 16384,
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini Chat 格式(`gemini:chat`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contents": [
|
||||||
|
{"role": "user", "parts": [{"text": "你好"}]}
|
||||||
|
],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.7,
|
||||||
|
"maxOutputTokens": 1024,
|
||||||
|
"topP": 0.9,
|
||||||
|
"topK": 40
|
||||||
|
},
|
||||||
|
"systemInstruction": {"parts": [{"text": "你是一个助手"}]},
|
||||||
|
"safetySettings": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** `model` 和 `stream` 是受保护字段,任何规则都无法修改它们。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI 表单说明
|
||||||
|
|
||||||
|
用户在端点管理对话框中点击「+ 请求体」添加规则。每条规则有一个下拉框选择操作类型,然后根据类型显示不同的输入框。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 覆写(set)
|
||||||
|
|
||||||
|
**UI 布局:** `[覆写 ▼]` `[字段路径]` `=` `[值]` `[✓]`
|
||||||
|
|
||||||
|
| 输入框 | 填什么 | 示例 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 字段路径 | 要设置的字段,用 `.` 分隔层级,`[N]` 访问数组 | `temperature` |
|
||||||
|
| 值 | **JSON 格式**的值。字符串要加引号,数字直接写 | `0.7` |
|
||||||
|
|
||||||
|
值输入框右边有验证图标:绿色勾=JSON合法,红色叉=格式错误。
|
||||||
|
|
||||||
|
**用户常见需求 → 怎么填:**
|
||||||
|
|
||||||
|
| 用户说 | 字段路径 | 值 |
|
||||||
|
|--------|---------|-----|
|
||||||
|
| "固定 temperature 为 0.3" | `temperature` | `0.3` |
|
||||||
|
| "限制最大输出 500 token" | `max_tokens` | `500` |
|
||||||
|
| "加一个 metadata 字段标记来源" | `metadata.source` | `"my-app"` |
|
||||||
|
| "设置 top_p 为 0.9" | `top_p` | `0.9` |
|
||||||
|
| "添加停止序列" | `stop` | `["\n", "###"]` |
|
||||||
|
| "设置响应格式为 JSON" | `response_format` | `{"type": "json_object"}` |
|
||||||
|
| "把第一条消息内容改掉" | `messages[0].content` | `"新的内容"` |
|
||||||
|
| "设置一个嵌套对象" | `metadata.tracking` | `{"id": "abc", "env": "prod"}` |
|
||||||
|
| "设置值为空" | `some_field` | `null` |
|
||||||
|
|
||||||
|
> **注意:** 字符串值必须加引号!`"hello"` 是字符串,`hello` 是无效 JSON。数字、布尔、null、数组、对象不需要额外引号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 删除(drop)
|
||||||
|
|
||||||
|
**UI 布局:** `[删除 ▼]` `[要删除的字段路径]`
|
||||||
|
|
||||||
|
| 输入框 | 填什么 | 示例 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 字段路径 | 要删除的字段路径 | `user_info.ip_address` |
|
||||||
|
|
||||||
|
**用户常见需求 → 怎么填:**
|
||||||
|
|
||||||
|
| 用户说 | 字段路径 |
|
||||||
|
|--------|---------|
|
||||||
|
| "去掉 user 字段" | `user` |
|
||||||
|
| "删除 metadata 里的 internal_flag" | `metadata.internal_flag` |
|
||||||
|
| "移除第一条消息" | `messages[0]` |
|
||||||
|
| "去掉 frequency_penalty" | `frequency_penalty` |
|
||||||
|
|
||||||
|
> **注意:** 删除数组元素会导致后续索引前移。如需删除多个数组元素,从后往前删。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 重命名(rename)
|
||||||
|
|
||||||
|
**UI 布局:** `[重命名 ▼]` `[原路径]` `→` `[新路径]`
|
||||||
|
|
||||||
|
| 输入框 | 填什么 | 示例 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 原路径 | 源字段的路径 | `extra.trace_id` |
|
||||||
|
| 新路径 | 目标字段的路径(中间层级会自动创建) | `metadata.request_id` |
|
||||||
|
|
||||||
|
**用户常见需求 → 怎么填:**
|
||||||
|
|
||||||
|
| 用户说 | 原路径 | 新路径 |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| "把 max_tokens 改名为 max_completion_tokens" | `max_tokens` | `max_completion_tokens` |
|
||||||
|
| "把 extra 里的 id 移到 metadata 下" | `extra.id` | `metadata.id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 插入(insert)
|
||||||
|
|
||||||
|
**UI 布局:** `[插入 ▼]` `[数组路径]` `[位置]` `[值(JSON)]` `[✓]`
|
||||||
|
|
||||||
|
| 输入框 | 填什么 | 示例 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 数组路径 | 目标数组的路径(必须是已有的数组) | `messages` |
|
||||||
|
| 位置 | 插入位置的数字索引,**留空=追加到末尾** | `0`(开头)或留空(末尾) |
|
||||||
|
| 值 | JSON 格式的元素 | `{"role": "system", "content": "..."}` |
|
||||||
|
|
||||||
|
**用户常见需求 → 怎么填:**
|
||||||
|
|
||||||
|
| 用户说 | 数组路径 | 位置 | 值 |
|
||||||
|
|--------|---------|------|-----|
|
||||||
|
| "在开头加一条 system 消息" | `messages` | `0` | `{"role": "system", "content": "你是一个专业助手"}` |
|
||||||
|
| "在末尾追加一条消息" | `messages` | (留空) | `{"role": "user", "content": "请用中文回答"}` |
|
||||||
|
| "在第二条消息前插入" | `messages` | `1` | `{"role": "assistant", "content": "好的"}` |
|
||||||
|
|
||||||
|
> **位置说明:** `0`=最前面,`1`=第二个位置,`-1`=倒数第一个前面。留空=追加到最后。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 正则替换(regex_replace)
|
||||||
|
|
||||||
|
**UI 布局:** `[正则替换 ▼]` `[字段路径]` `[正则]` `→` `[替换为]` `[ims]` `[✓]`
|
||||||
|
|
||||||
|
| 输入框 | 填什么 | 示例 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 字段路径 | 目标字符串字段的路径 | `messages[-1].content` |
|
||||||
|
| 正则 | 正则表达式(**不需要**加 `/` 包裹) | `1[3-9]\d{9}` |
|
||||||
|
| 替换为 | 替换成什么,留空=删除匹配内容 | `[手机号已隐藏]` |
|
||||||
|
| ims | 可选标志,留空=默认 | `i` |
|
||||||
|
|
||||||
|
> **重要:** 正则输入框里直接写正则语法即可,**不需要** JSON 转义。`\d` 就写 `\d`,不用写 `\\d`。JSON 转义是保存时系统自动处理的。
|
||||||
|
|
||||||
|
**flags 含义:**
|
||||||
|
|
||||||
|
| 字母 | 作用 | 什么时候用 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `i` | 忽略大小写 | 匹配 `hello`/`Hello`/`HELLO` |
|
||||||
|
| `m` | 多行模式 | `^`/`$` 匹配每行而非整个字符串 |
|
||||||
|
| `s` | dotall | `.` 能匹配换行符 |
|
||||||
|
|
||||||
|
大多数情况留空即可。
|
||||||
|
|
||||||
|
**用户常见需求 → 怎么填:**
|
||||||
|
|
||||||
|
| 用户说 | 字段路径 | 正则 | 替换为 | flags |
|
||||||
|
|--------|---------|------|--------|-------|
|
||||||
|
| "隐藏最后一条消息里的手机号" | `messages[-1].content` | `1[3-9]\d{9}` | `[手机号已隐藏]` | |
|
||||||
|
| "隐藏邮箱地址" | `messages[-1].content` | `[\w.+-]+@[\w.-]+\.\w{2,}` | `[邮箱已隐藏]` | `i` |
|
||||||
|
| "去掉 HTML 标签" | `messages[-1].content` | `<[^>]+>` | (留空) | |
|
||||||
|
| "把所有的 foo 替换成 bar" | `messages[-1].content` | `\bfoo\b` | `bar` | |
|
||||||
|
| "删掉 Markdown 加粗标记" | `messages[-1].content` | `\*\*([^*]+)\*\*` | `\1` | |
|
||||||
|
| "隐藏身份证号" | `messages[-1].content` | `\d{17}[\dXx]` | `[身份证已隐藏]` | |
|
||||||
|
| "隐藏 IP 地址" | `messages[-1].content` | `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` | `[IP已隐藏]` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 路径语法速查
|
||||||
|
|
||||||
|
| 写法 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `temperature` | 顶层字段 |
|
||||||
|
| `metadata.source` | 嵌套字段 |
|
||||||
|
| `messages[0]` | 数组第一个元素 |
|
||||||
|
| `messages[-1]` | 数组最后一个元素 |
|
||||||
|
| `messages[0].content` | 第一条消息的 content |
|
||||||
|
| `messages[-1].content` | 最后一条消息的 content |
|
||||||
|
| `data[0].items[2]` | 嵌套数组访问 |
|
||||||
|
| `config\.v1.enabled` | key 名字里有点号时用 `\.` 转义 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 多条规则示例
|
||||||
|
|
||||||
|
用户说:"帮我配置规则:在开头插入一条 system 消息说'用中文回答',固定 temperature 为 0.3,把用户消息里的手机号脱敏"
|
||||||
|
|
||||||
|
应指导用户添加 3 条规则:
|
||||||
|
|
||||||
|
| # | 操作 | 字段 1 | 字段 2 | 字段 3 | 字段 4 |
|
||||||
|
|---|------|--------|--------|--------|--------|
|
||||||
|
| 1 | 插入 | 路径: `messages` | 位置: `0` | 值: `{"role": "system", "content": "请用中文回答所有问题"}` | |
|
||||||
|
| 2 | 覆写 | 路径: `temperature` | 值: `0.3` | | |
|
||||||
|
| 3 | 正则替换 | 路径: `messages[-1].content` | 正则: `1[3-9]\d{9}` | 替换为: `[手机号]` | flags: (留空) |
|
||||||
|
|
||||||
|
规则按从上到下的顺序执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **`model` 和 `stream` 不可修改** — 这两个字段由系统管理,写了规则也会被跳过
|
||||||
|
2. **值必须是合法 JSON** — 覆写/插入的值输入框要求 JSON 格式:字符串加引号 `"text"`,数字直接写 `123`,布尔 `true`/`false`
|
||||||
|
3. **路径必须指向正确类型** — 插入的路径必须是数组,正则替换的路径必须是字符串
|
||||||
|
4. **路径不存在时的行为** — 覆写会自动创建中间层级(dict),其他操作遇到不存在的路径会静默跳过
|
||||||
|
5. **正则在 UI 里直接写** — 不需要 JSON 转义,`\d` 就是 `\d`
|
||||||
|
6. **正则保存时校验** — 无效的正则表达式会在保存时报错,不会静默通过
|
||||||
@@ -208,10 +208,12 @@ export interface BatchImportResult {
|
|||||||
|
|
||||||
export async function batchImportOAuth(
|
export async function batchImportOAuth(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
credentials: string
|
credentials: string,
|
||||||
|
proxyNodeId?: string
|
||||||
): Promise<BatchImportResult> {
|
): Promise<BatchImportResult> {
|
||||||
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
|
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
|
||||||
credentials,
|
credentials,
|
||||||
|
proxy_node_id: proxyNodeId || undefined,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ProviderOAuthStartResponse {
|
|||||||
export interface ProviderOAuthCompleteRequest {
|
export interface ProviderOAuthCompleteRequest {
|
||||||
callback_url: string
|
callback_url: string
|
||||||
name?: string
|
name?: string
|
||||||
|
proxy_node_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderOAuthCompleteResponse {
|
export interface ProviderOAuthCompleteResponse {
|
||||||
@@ -49,7 +50,7 @@ export async function completeProviderLevelOAuth(
|
|||||||
|
|
||||||
export async function importProviderRefreshToken(
|
export async function importProviderRefreshToken(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
data: { refresh_token: string; name?: string }
|
data: { refresh_token: string; name?: string; proxy_node_id?: string }
|
||||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||||
return resp.data
|
return resp.data
|
||||||
|
|||||||
@@ -159,7 +159,51 @@ export interface BodyRuleRename {
|
|||||||
to: string
|
to: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename
|
/**
|
||||||
|
* 请求体规则 - 向数组追加元素
|
||||||
|
*
|
||||||
|
* - path 指向目标数组,如 "messages"
|
||||||
|
* - value 为要追加的元素
|
||||||
|
*/
|
||||||
|
export interface BodyRuleAppend {
|
||||||
|
action: 'append'
|
||||||
|
path: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体规则 - 在数组指定位置插入元素
|
||||||
|
*
|
||||||
|
* - path 指向目标数组,如 "messages"
|
||||||
|
* - index 为插入位置(支持负数)
|
||||||
|
* - value 为要插入的元素
|
||||||
|
*/
|
||||||
|
export interface BodyRuleInsert {
|
||||||
|
action: 'insert'
|
||||||
|
path: string
|
||||||
|
index: number
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体规则 - 正则替换字符串值
|
||||||
|
*
|
||||||
|
* - path 指向目标字符串字段,如 "messages[0].content"
|
||||||
|
* - pattern 为正则表达式
|
||||||
|
* - replacement 为替换字符串
|
||||||
|
* - flags 可选,支持 i(忽略大小写)/m(多行)/s(dotall)
|
||||||
|
* - count 替换次数,0=全部替换(默认)
|
||||||
|
*/
|
||||||
|
export interface BodyRuleRegexReplace {
|
||||||
|
action: 'regex_replace'
|
||||||
|
path: string
|
||||||
|
pattern: string
|
||||||
|
replacement: string
|
||||||
|
flags?: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式接受策略配置
|
* 格式接受策略配置
|
||||||
|
|||||||
92
frontend/src/composables/useSmartPagination.ts
Normal file
92
frontend/src/composables/useSmartPagination.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能分页 composable
|
||||||
|
*
|
||||||
|
* 根据列表容器的实际渲染高度自动决定是否分页以及每页条数。
|
||||||
|
* 当列表总高度超过阈值时自动启用分页,低于阈值时恢复全量显示。
|
||||||
|
*
|
||||||
|
* @param items 响应式数据源(全量列表)
|
||||||
|
* @param listRef 列表容器 DOM 引用
|
||||||
|
* @param maxHeight 触发分页的高度阈值(px),默认 500
|
||||||
|
*/
|
||||||
|
export function useSmartPagination<T>(
|
||||||
|
items: ComputedRef<T[]> | Ref<T[]>,
|
||||||
|
listRef: Ref<HTMLElement | null>,
|
||||||
|
maxHeight = 500,
|
||||||
|
) {
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(0) // 0 = 不分页
|
||||||
|
const cachedAvgItemHeight = ref(0)
|
||||||
|
|
||||||
|
const shouldPaginate = computed(() => {
|
||||||
|
return itemsPerPage.value > 0 && items.value.length > itemsPerPage.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
if (!shouldPaginate.value) return items.value
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||||
|
return items.value.slice(start, start + itemsPerPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (!shouldPaginate.value) return 1
|
||||||
|
return Math.ceil(items.value.length / itemsPerPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 将当前页内的局部索引转换为全局索引 */
|
||||||
|
function getGlobalIndex(localIdx: number): number {
|
||||||
|
if (!shouldPaginate.value) return localIdx
|
||||||
|
return (currentPage.value - 1) * itemsPerPage.value + localIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测是否需要分页并计算每页条数 */
|
||||||
|
function detect() {
|
||||||
|
const el = listRef.value
|
||||||
|
if (!el || items.value.length <= 2) {
|
||||||
|
itemsPerPage.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollHeight = el.scrollHeight
|
||||||
|
const renderedCount = paginatedItems.value.length
|
||||||
|
|
||||||
|
if (renderedCount > 0 && scrollHeight > 0) {
|
||||||
|
cachedAvgItemHeight.value = scrollHeight / renderedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedTotalHeight = cachedAvgItemHeight.value * items.value.length
|
||||||
|
if (estimatedTotalHeight > maxHeight && cachedAvgItemHeight.value > 0) {
|
||||||
|
itemsPerPage.value = Math.max(Math.floor(maxHeight / cachedAvgItemHeight.value), 3)
|
||||||
|
const maxPage = Math.ceil(items.value.length / itemsPerPage.value)
|
||||||
|
if (currentPage.value > maxPage) {
|
||||||
|
currentPage.value = maxPage
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsPerPage.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置分页状态(数据源切换时调用) */
|
||||||
|
function reset() {
|
||||||
|
currentPage.value = 1
|
||||||
|
itemsPerPage.value = 0
|
||||||
|
cachedAvgItemHeight.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据源变化时自动重新检测(immediate 确保首次挂载时也检测)
|
||||||
|
watch(items, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
nextTick(detect)
|
||||||
|
}, { immediate: true, flush: 'post' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
shouldPaginate,
|
||||||
|
paginatedItems,
|
||||||
|
getGlobalIndex,
|
||||||
|
detect,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
||||||
class="text-xs text-muted-foreground px-2"
|
class="text-xs text-muted-foreground px-2"
|
||||||
>
|
>
|
||||||
用 <code class="bg-muted px-1 rounded">.</code> 访问嵌套字段;值为 JSON 格式,字符串需加引号如 <code class="bg-muted px-1 rounded">"text"</code>
|
<code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引;值为 JSON 格式
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 请求体规则列表 - 次要色边框 -->
|
<!-- 请求体规则列表 - 次要色边框 -->
|
||||||
@@ -312,10 +312,10 @@
|
|||||||
<Select
|
<Select
|
||||||
:model-value="rule.action"
|
:model-value="rule.action"
|
||||||
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
|
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
|
||||||
@update:model-value="(v) => updateEndpointBodyRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
@update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
|
||||||
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
|
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -328,6 +328,12 @@
|
|||||||
<SelectItem value="rename">
|
<SelectItem value="rename">
|
||||||
重命名
|
重命名
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="insert">
|
||||||
|
插入
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="regex_replace">
|
||||||
|
正则替换
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<template v-if="rule.action === 'set'">
|
<template v-if="rule.action === 'set'">
|
||||||
@@ -378,6 +384,72 @@
|
|||||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="rule.action === 'insert' || 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)"
|
||||||
|
/>
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -519,6 +591,7 @@ import {
|
|||||||
type ProviderWithEndpointsSummary,
|
type ProviderWithEndpointsSummary,
|
||||||
type HeaderRule,
|
type HeaderRule,
|
||||||
type BodyRule,
|
type BodyRule,
|
||||||
|
type BodyRuleRegexReplace,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
@@ -532,12 +605,18 @@ interface EditableRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编辑用的请求体规则类型
|
// 编辑用的请求体规则类型
|
||||||
|
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace'
|
||||||
|
|
||||||
interface EditableBodyRule {
|
interface EditableBodyRule {
|
||||||
action: 'set' | 'drop' | 'rename'
|
action: BodyRuleAction
|
||||||
path: string // set/drop 用
|
path: string // set/drop/append/insert/regex_replace 用
|
||||||
value: string // set 用(JSON 格式)
|
value: string // set/append/insert 用(JSON 格式)
|
||||||
from: string // rename 用
|
from: string // rename 用
|
||||||
to: string // rename 用
|
to: string // rename 用
|
||||||
|
index: string // insert 用(字符串输入,保存时解析为 int)
|
||||||
|
pattern: string // regex_replace 用
|
||||||
|
replacement: string // regex_replace 用
|
||||||
|
flags: string // regex_replace 用(i/m/s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
||||||
@@ -779,16 +858,29 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
|
||||||
|
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '',
|
||||||
|
})
|
||||||
|
|
||||||
const bodyRules: EditableBodyRule[] = []
|
const bodyRules: EditableBodyRule[] = []
|
||||||
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||||
for (const rule of endpoint.body_rules) {
|
for (const rule of endpoint.body_rules) {
|
||||||
if (rule.action === 'set') {
|
if (rule.action === 'set') {
|
||||||
const { value } = initBodyRuleSetValueForEditor((rule as any).value)
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
bodyRules.push({ action: 'set', path: rule.path, value, from: '', to: '' })
|
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value })
|
||||||
} else if (rule.action === 'drop') {
|
} else if (rule.action === 'drop') {
|
||||||
bodyRules.push({ action: 'drop', path: rule.path, value: '', from: '', to: '' })
|
bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path })
|
||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
bodyRules.push({ action: 'rename', path: '', value: '', from: rule.from, to: rule.to })
|
bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to })
|
||||||
|
} else if (rule.action === 'append') {
|
||||||
|
// 前端将 append 统一展示为 insert(index 留空),保存时再根据 index 是否为空转回 append
|
||||||
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
|
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '' })
|
||||||
|
} else if (rule.action === 'insert') {
|
||||||
|
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||||
|
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? '') })
|
||||||
|
} else if (rule.action === 'regex_replace') {
|
||||||
|
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -977,7 +1069,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
|
|||||||
// 添加请求体规则(同时自动展开折叠)
|
// 添加请求体规则(同时自动展开折叠)
|
||||||
function handleAddEndpointBodyRule(endpointId: string) {
|
function handleAddEndpointBodyRule(endpointId: string) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
rules.push({ action: 'set', path: '', value: '', from: '', to: '' })
|
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '' })
|
||||||
// 自动展开折叠
|
// 自动展开折叠
|
||||||
endpointRulesExpanded.value[endpointId] = true
|
endpointRulesExpanded.value[endpointId] = true
|
||||||
}
|
}
|
||||||
@@ -989,7 +1081,7 @@ function removeEndpointBodyRule(endpointId: string, index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新请求体规则类型
|
// 更新请求体规则类型
|
||||||
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
|
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index].action = action
|
rules[index].action = action
|
||||||
@@ -997,11 +1089,15 @@ function updateEndpointBodyRuleAction(endpointId: string, index: number, action:
|
|||||||
rules[index].value = ''
|
rules[index].value = ''
|
||||||
rules[index].from = ''
|
rules[index].from = ''
|
||||||
rules[index].to = ''
|
rules[index].to = ''
|
||||||
|
rules[index].index = ''
|
||||||
|
rules[index].pattern = ''
|
||||||
|
rules[index].replacement = ''
|
||||||
|
rules[index].flags = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新请求体规则字段
|
// 更新请求体规则字段
|
||||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to', value: string) {
|
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags', value: string) {
|
||||||
const rules = getEndpointEditBodyRules(endpointId)
|
const rules = getEndpointEditBodyRules(endpointId)
|
||||||
if (rules[index]) {
|
if (rules[index]) {
|
||||||
rules[index][field] = value
|
rules[index][field] = value
|
||||||
@@ -1013,11 +1109,14 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
|||||||
const raw = path.trim()
|
const raw = path.trim()
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
|
|
||||||
const parts = parseBodyRulePathParts(raw)
|
// 基础格式校验;对含 [N] 的路径,取方括号前的部分做 dot 校验
|
||||||
|
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
|
||||||
|
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
return '路径格式无效'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取顶层 key(去除数组索引部分)
|
||||||
const topKey = (parts[0] || '').trim().toLowerCase()
|
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||||
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||||
return `"${parts[0]}" 是系统保留的顶层字段`
|
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||||
@@ -1101,7 +1200,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateBodySetValue(rule: EditableBodyRule): string | null {
|
function validateBodySetValue(rule: EditableBodyRule): string | null {
|
||||||
if (rule.action !== 'set') return null
|
if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
|
||||||
|
|
||||||
const raw = rule.value.trim()
|
const raw = rule.value.trim()
|
||||||
if (!raw) return '值不能为空'
|
if (!raw) return '值不能为空'
|
||||||
@@ -1116,7 +1215,7 @@ function validateBodySetValue(rule: EditableBodyRule): string | null {
|
|||||||
|
|
||||||
// 获取值验证状态:true=有效, false=无效, null=空
|
// 获取值验证状态:true=有效, false=无效, null=空
|
||||||
function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
||||||
if (rule.action !== 'set') return null
|
if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
|
||||||
const raw = rule.value.trim()
|
const raw = rule.value.trim()
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
@@ -1127,6 +1226,41 @@ function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正则表达式验证状态: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: any) {
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取验证提示
|
// 获取验证提示
|
||||||
function getBodySetValueValidationTip(rule: EditableBodyRule): string {
|
function getBodySetValueValidationTip(rule: EditableBodyRule): string {
|
||||||
const validation = getBodySetValueValidation(rule)
|
const validation = getBodySetValueValidation(rule)
|
||||||
@@ -1151,6 +1285,8 @@ function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
|
|||||||
return state.bodyRules.filter(r => {
|
return state.bodyRules.filter(r => {
|
||||||
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||||
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||||
|
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
|
||||||
|
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
|
||||||
return false
|
return false
|
||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1333,13 @@ function _formatBodyRuleLabel(rule: EditableBodyRule): string {
|
|||||||
} else if (rule.action === 'rename') {
|
} else if (rule.action === 'rename') {
|
||||||
if (!rule.from || !rule.to) return '(未设置)'
|
if (!rule.from || !rule.to) return '(未设置)'
|
||||||
return `${rule.from}→${rule.to}`
|
return `${rule.from}→${rule.to}`
|
||||||
|
} else if (rule.action === 'insert' || rule.action === 'append') {
|
||||||
|
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 '(未设置)'
|
||||||
|
return `${rule.path}: s/${rule.pattern}/${rule.replacement || ''}/`
|
||||||
}
|
}
|
||||||
return '(未知)'
|
return '(未知)'
|
||||||
}
|
}
|
||||||
@@ -1210,6 +1353,8 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
const editedRules = state.bodyRules.filter(r => {
|
const editedRules = state.bodyRules.filter(r => {
|
||||||
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||||
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||||
|
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
|
||||||
|
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (editedRules.length !== originalRules.length) return true
|
if (editedRules.length !== originalRules.length) return true
|
||||||
@@ -1219,13 +1364,29 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
if (!original) return true
|
if (!original) return true
|
||||||
if (edited.action !== original.action) return true
|
if (edited.action !== original.action) return true
|
||||||
if (edited.action === 'set' && original.action === 'set') {
|
if (edited.action === 'set' && original.action === 'set') {
|
||||||
const baseline = initBodyRuleSetValueForEditor((original as any).value)
|
const baseline = initBodyRuleSetValueForEditor(original.value)
|
||||||
if (edited.path !== original.path) return true
|
if (edited.path !== original.path) return true
|
||||||
if (edited.value !== baseline.value) return true
|
if (edited.value !== baseline.value) return true
|
||||||
} else if (edited.action === 'drop' && original.action === 'drop') {
|
} else if (edited.action === 'drop' && original.action === 'drop') {
|
||||||
if (edited.path !== original.path) return true
|
if (edited.path !== original.path) return true
|
||||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||||
if (edited.from !== original.from || edited.to !== original.to) return true
|
if (edited.from !== original.from || edited.to !== original.to) return true
|
||||||
|
} else if (edited.action === 'insert' && original.action === 'append') {
|
||||||
|
// append 加载时被标准化为 insert(index 为空),比对时需跨 action 匹配
|
||||||
|
const baseline = initBodyRuleSetValueForEditor(original.value)
|
||||||
|
if (edited.index.trim() !== '') return true // 加了 index → 已修改
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -1238,17 +1399,33 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
|||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.action === 'set' && rule.path.trim()) {
|
if (rule.action === 'set' && rule.path.trim()) {
|
||||||
let value: any = rule.value
|
let value: any = rule.value
|
||||||
try {
|
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
|
||||||
value = JSON.parse(rule.value.trim())
|
|
||||||
} catch {
|
|
||||||
// 保存前会做校验;这里兜底避免 UI 崩溃
|
|
||||||
value = rule.value
|
|
||||||
}
|
|
||||||
result.push({ action: 'set', path: rule.path.trim(), value })
|
result.push({ action: 'set', path: rule.path.trim(), value })
|
||||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||||
result.push({ action: 'drop', path: rule.path.trim() })
|
result.push({ action: 'drop', path: rule.path.trim() })
|
||||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||||
|
} else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
|
||||||
|
let value: any = rule.value
|
||||||
|
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
|
||||||
|
const indexStr = rule.index.trim()
|
||||||
|
if (indexStr === '') {
|
||||||
|
// 索引留空 → append 到末尾
|
||||||
|
result.push({ action: 'append', path: rule.path.trim(), value })
|
||||||
|
} else {
|
||||||
|
const idx = parseInt(indexStr, 10)
|
||||||
|
if (isNaN(idx)) continue
|
||||||
|
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value })
|
||||||
|
}
|
||||||
|
} 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() } : {}),
|
||||||
|
}
|
||||||
|
result.push(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,6 +1450,29 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
|||||||
if (fromErr) return `${prefix}${fromErr}`
|
if (fromErr) return `${prefix}${fromErr}`
|
||||||
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
|
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
|
||||||
if (toErr) return `${prefix}${toErr}`
|
if (toErr) return `${prefix}${toErr}`
|
||||||
|
} else if (rule.action === 'insert' || rule.action === 'append') {
|
||||||
|
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||||
|
if (pathErr) return `${prefix}${pathErr}`
|
||||||
|
const indexStr = rule.index.trim()
|
||||||
|
if (indexStr !== '' && isNaN(parseInt(indexStr, 10))) 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: any) {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -6,6 +6,58 @@
|
|||||||
size="md"
|
size="md"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
|
<!-- 右上角代理按钮 -->
|
||||||
|
<template #header-actions>
|
||||||
|
<Popover
|
||||||
|
:open="proxyPopoverOpen"
|
||||||
|
@update:open="(v: boolean) => { proxyPopoverOpen = v; if (v) proxyNodesStore.ensureLoaded() }"
|
||||||
|
>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors shrink-0"
|
||||||
|
:class="selectedProxyNodeId
|
||||||
|
? 'text-blue-500 bg-blue-500/10 hover:bg-blue-500/20'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||||
|
:title="selectedProxyNodeId ? `代理: ${getSelectedNodeLabel()}` : '设置代理节点'"
|
||||||
|
>
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
class="w-72 p-3 z-[80]"
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs font-medium">代理节点</span>
|
||||||
|
<span
|
||||||
|
v-if="!proxyNodesStore.loading && proxyNodesStore.onlineNodes.length === 0"
|
||||||
|
class="text-[10px] text-muted-foreground"
|
||||||
|
>· 前往「模块管理 · 代理节点」添加</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedProxyNodeId"
|
||||||
|
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
@click="selectedProxyNodeId = ''; proxyPopoverOpen = false"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ProxyNodeSelect
|
||||||
|
:model-value="selectedProxyNodeId"
|
||||||
|
trigger-class="h-8"
|
||||||
|
@update:model-value="(v: string) => { selectedProxyNodeId = v; proxyPopoverOpen = false }"
|
||||||
|
/>
|
||||||
|
<p class="text-[10px] text-muted-foreground">
|
||||||
|
{{ selectedProxyNodeId ? '授权、刷新、额度查询均走此代理' : '未设置,依次回退到提供商代理 → 系统代理' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Tab 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
|
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
|
||||||
@@ -227,8 +279,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||||
import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next'
|
import { UserPlus, Copy, ExternalLink, Upload, Globe } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
@@ -238,6 +290,8 @@ import {
|
|||||||
importProviderRefreshToken,
|
importProviderRefreshToken,
|
||||||
batchImportOAuth,
|
batchImportOAuth,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
|
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||||
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -252,6 +306,18 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
|
// 代理节点选择
|
||||||
|
const proxyPopoverOpen = ref(false)
|
||||||
|
const selectedProxyNodeId = ref('')
|
||||||
|
|
||||||
|
/** 获取已选代理节点的显示名称 */
|
||||||
|
function getSelectedNodeLabel(): string {
|
||||||
|
if (!selectedProxyNodeId.value) return ''
|
||||||
|
const node = proxyNodesStore.nodes.find(n => n.id === selectedProxyNodeId.value)
|
||||||
|
return node ? node.name : `${selectedProxyNodeId.value.slice(0, 8) }...`
|
||||||
|
}
|
||||||
|
|
||||||
// 模式
|
// 模式
|
||||||
type DialogMode = 'oauth' | 'import'
|
type DialogMode = 'oauth' | 'import'
|
||||||
@@ -318,6 +384,8 @@ function resetForm() {
|
|||||||
importing.value = false
|
importing.value = false
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
showManualInput.value = false
|
showManualInput.value = false
|
||||||
|
proxyPopoverOpen.value = false
|
||||||
|
selectedProxyNodeId.value = ''
|
||||||
mode.value = isKiroProvider.value ? 'import' : 'oauth'
|
mode.value = isKiroProvider.value ? 'import' : 'oauth'
|
||||||
if (fileInputRef.value) {
|
if (fileInputRef.value) {
|
||||||
fileInputRef.value.value = ''
|
fileInputRef.value.value = ''
|
||||||
@@ -393,6 +461,7 @@ async function handleCompleteOAuth() {
|
|||||||
try {
|
try {
|
||||||
await completeProviderLevelOAuth(props.providerId, {
|
await completeProviderLevelOAuth(props.providerId, {
|
||||||
callback_url: oauth.value.callback_url.trim(),
|
callback_url: oauth.value.callback_url.trim(),
|
||||||
|
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||||
})
|
})
|
||||||
success('授权成功,账号已添加')
|
success('授权成功,账号已添加')
|
||||||
emit('saved')
|
emit('saved')
|
||||||
@@ -498,10 +567,11 @@ async function handleImport() {
|
|||||||
|
|
||||||
importing.value = true
|
importing.value = true
|
||||||
try {
|
try {
|
||||||
|
const proxyNodeId = selectedProxyNodeId.value || undefined
|
||||||
// 检测是否为批量导入
|
// 检测是否为批量导入
|
||||||
if (isBatchImport(inputText)) {
|
if (isBatchImport(inputText)) {
|
||||||
// 批量导入
|
// 批量导入
|
||||||
const result = await batchImportOAuth(props.providerId, inputText)
|
const result = await batchImportOAuth(props.providerId, inputText, proxyNodeId)
|
||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||||
@@ -522,7 +592,10 @@ async function handleImport() {
|
|||||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await importProviderRefreshToken(props.providerId, parsed)
|
await importProviderRefreshToken(props.providerId, {
|
||||||
|
...parsed,
|
||||||
|
proxy_node_id: proxyNodeId,
|
||||||
|
})
|
||||||
success('导入成功,账号已添加')
|
success('导入成功,账号已添加')
|
||||||
emit('saved')
|
emit('saved')
|
||||||
handleClose()
|
handleClose()
|
||||||
@@ -537,6 +610,8 @@ async function handleImport() {
|
|||||||
|
|
||||||
watch(() => props.open, (newOpen) => {
|
watch(() => props.open, (newOpen) => {
|
||||||
if (newOpen) {
|
if (newOpen) {
|
||||||
|
// 预加载代理节点列表
|
||||||
|
proxyNodesStore.ensureLoaded()
|
||||||
if (isKiroProvider.value) {
|
if (isKiroProvider.value) {
|
||||||
mode.value = 'import'
|
mode.value = 'import'
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -206,23 +206,24 @@
|
|||||||
<!-- 密钥列表 -->
|
<!-- 密钥列表 -->
|
||||||
<div
|
<div
|
||||||
v-if="allKeys.length > 0"
|
v-if="allKeys.length > 0"
|
||||||
|
ref="keysListRef"
|
||||||
class="divide-y divide-border/40"
|
class="divide-y divide-border/40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="({ key, endpoint }, index) in allKeys"
|
v-for="({ key, endpoint }, localIdx) in paginatedKeys"
|
||||||
:key="key.id"
|
:key="key.id"
|
||||||
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
||||||
:class="{
|
:class="{
|
||||||
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index,
|
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === getGlobalKeyIndex(localIdx),
|
||||||
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging,
|
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === getGlobalKeyIndex(localIdx) && keyDragState.isDragging,
|
||||||
'opacity-40 bg-muted/20': !key.is_active
|
'opacity-40 bg-muted/20': !key.is_active
|
||||||
}"
|
}"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="handleKeyDragStart($event, index)"
|
@dragstart="handleKeyDragStart($event, getGlobalKeyIndex(localIdx))"
|
||||||
@dragend="handleKeyDragEnd"
|
@dragend="handleKeyDragEnd"
|
||||||
@dragover="handleKeyDragOver($event, index)"
|
@dragover="handleKeyDragOver($event, getGlobalKeyIndex(localIdx))"
|
||||||
@dragleave="handleKeyDragLeave"
|
@dragleave="handleKeyDragLeave"
|
||||||
@drop="handleKeyDrop($event, index)"
|
@drop="handleKeyDrop($event, getGlobalKeyIndex(localIdx))"
|
||||||
>
|
>
|
||||||
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
@@ -760,6 +761,34 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 分页控制 -->
|
||||||
|
<div
|
||||||
|
v-if="shouldPaginateKeys"
|
||||||
|
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>共 {{ allKeys.length }} 个{{ provider.provider_type === 'custom' ? '密钥' : '账号' }}</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentKeyPage <= 1"
|
||||||
|
@click="currentKeyPage--"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span class="tabular-nums">{{ currentKeyPage }} / {{ totalKeyPages }}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentKeyPage >= totalKeyPages"
|
||||||
|
@click="currentKeyPage++"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -900,6 +929,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, nextTick } from 'vue'
|
import { ref, watch, computed, nextTick } from 'vue'
|
||||||
|
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Key,
|
Key,
|
||||||
@@ -1104,6 +1134,17 @@ const allKeys = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 账号列表智能分页 =====
|
||||||
|
const keysListRef = ref<HTMLElement | null>(null)
|
||||||
|
const {
|
||||||
|
currentPage: currentKeyPage,
|
||||||
|
totalPages: totalKeyPages,
|
||||||
|
shouldPaginate: shouldPaginateKeys,
|
||||||
|
paginatedItems: paginatedKeys,
|
||||||
|
getGlobalIndex: getGlobalKeyIndex,
|
||||||
|
reset: resetKeysPagination,
|
||||||
|
} = useSmartPagination(allKeys, keysListRef)
|
||||||
|
|
||||||
// 合并监听 providerId 和 open,避免同一 tick 内两个 watcher 都触发导致重复请求
|
// 合并监听 providerId 和 open,避免同一 tick 内两个 watcher 都触发导致重复请求
|
||||||
watch(
|
watch(
|
||||||
[() => props.providerId, () => props.open],
|
[() => props.providerId, () => props.open],
|
||||||
@@ -1126,6 +1167,9 @@ watch(
|
|||||||
endpoints.value = []
|
endpoints.value = []
|
||||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||||
|
|
||||||
|
// 重置分页状态
|
||||||
|
resetKeysPagination()
|
||||||
|
|
||||||
// 重置所有对话框状态
|
// 重置所有对话框状态
|
||||||
endpointDialogOpen.value = false
|
endpointDialogOpen.value = false
|
||||||
keyFormDialogOpen.value = false
|
keyFormDialogOpen.value = false
|
||||||
|
|||||||
@@ -24,12 +24,6 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p
|
|
||||||
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无在线代理节点,请在「代理节点」页面添加
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -29,23 +29,24 @@
|
|||||||
<!-- 映射列表 -->
|
<!-- 映射列表 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="combinedMappings.length > 0"
|
v-else-if="combinedMappings.length > 0"
|
||||||
|
ref="mappingsListRef"
|
||||||
class="divide-y divide-border/40"
|
class="divide-y divide-border/40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in combinedMappings"
|
v-for="item in paginatedMappings"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="transition-colors"
|
class="transition-colors"
|
||||||
>
|
>
|
||||||
<!-- 行头部(可点击展开) -->
|
<!-- 行头部(可点击展开) -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
|
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
|
||||||
@click="toggleExpand(index)"
|
@click="toggleExpand(item.key)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<!-- 展开/收起图标 -->
|
<!-- 展开/收起图标 -->
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
|
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
|
||||||
:class="{ 'rotate-90': expandedItems.has(index) }"
|
:class="{ 'rotate-90': expandedItems.has(item.key) }"
|
||||||
/>
|
/>
|
||||||
<!-- 精确映射 -->
|
<!-- 精确映射 -->
|
||||||
<template v-if="item.type === 'exact'">
|
<template v-if="item.type === 'exact'">
|
||||||
@@ -134,7 +135,7 @@
|
|||||||
|
|
||||||
<!-- 展开的映射详情 -->
|
<!-- 展开的映射详情 -->
|
||||||
<div
|
<div
|
||||||
v-show="expandedItems.has(index)"
|
v-show="expandedItems.has(item.key)"
|
||||||
class="bg-muted/30 border-t border-border/30"
|
class="bg-muted/30 border-t border-border/30"
|
||||||
>
|
>
|
||||||
<!-- 精确映射详情 -->
|
<!-- 精确映射详情 -->
|
||||||
@@ -272,6 +273,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 分页控制 -->
|
||||||
|
<div
|
||||||
|
v-if="shouldPaginateMappings"
|
||||||
|
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>共 {{ combinedMappings.length }} 个映射</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentMappingPage <= 1"
|
||||||
|
@click="currentMappingPage--"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span class="tabular-nums">{{ currentMappingPage }} / {{ totalMappingPages }}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentMappingPage >= totalMappingPages"
|
||||||
|
@click="currentMappingPage++"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -315,6 +344,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Card, Button, Badge,
|
Card, Button, Badge,
|
||||||
@@ -393,7 +423,7 @@ const providerKeysState = ref<EndpointAPIKey[]>([])
|
|||||||
const formatMenuOpen = ref<Record<string, boolean>>({})
|
const formatMenuOpen = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
// 展开状态
|
// 展开状态
|
||||||
const expandedItems = ref<Set<number>>(new Set())
|
const expandedItems = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 是否有 key 配置了自动获取上游模型
|
// 是否有 key 配置了自动获取上游模型
|
||||||
const hasAutoFetchKey = computed(() => {
|
const hasAutoFetchKey = computed(() => {
|
||||||
@@ -523,6 +553,15 @@ const combinedMappings = computed<CombinedMapping[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 模型映射智能分页 =====
|
||||||
|
const mappingsListRef = ref<HTMLElement | null>(null)
|
||||||
|
const {
|
||||||
|
currentPage: currentMappingPage,
|
||||||
|
totalPages: totalMappingPages,
|
||||||
|
shouldPaginate: shouldPaginateMappings,
|
||||||
|
paginatedItems: paginatedMappings,
|
||||||
|
} = useSmartPagination(combinedMappings, mappingsListRef)
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
@@ -554,11 +593,11 @@ const deleteConfirmDescription = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 切换展开状态
|
// 切换展开状态
|
||||||
function toggleExpand(index: number) {
|
function toggleExpand(key: string) {
|
||||||
if (expandedItems.value.has(index)) {
|
if (expandedItems.value.has(key)) {
|
||||||
expandedItems.value.delete(index)
|
expandedItems.value.delete(key)
|
||||||
} else {
|
} else {
|
||||||
expandedItems.value.add(index)
|
expandedItems.value.add(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
v-else-if="models.length > 0"
|
v-else-if="models.length > 0"
|
||||||
class="overflow-hidden"
|
class="overflow-hidden"
|
||||||
>
|
>
|
||||||
<table class="w-full text-sm table-fixed">
|
<table
|
||||||
|
ref="modelsListRef"
|
||||||
|
class="w-full text-sm table-fixed"
|
||||||
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-[45%]">
|
<col class="w-[45%]">
|
||||||
<col class="w-[30%]">
|
<col class="w-[30%]">
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="model in sortedModels"
|
v-for="model in paginatedModels"
|
||||||
:key="model.id"
|
:key="model.id"
|
||||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -196,6 +199,34 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<!-- 分页控制 -->
|
||||||
|
<div
|
||||||
|
v-if="shouldPaginateModels"
|
||||||
|
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>共 {{ sortedModels.length }} 个模型</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentModelPage <= 1"
|
||||||
|
@click="currentModelPage--"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span class="tabular-nums">{{ currentModelPage }} / {{ totalModelPages }}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
:disabled="currentModelPage >= totalModelPages"
|
||||||
|
@click="currentModelPage++"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -216,6 +247,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -283,6 +315,15 @@ const sortedModels = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 模型列表智能分页 =====
|
||||||
|
const modelsListRef = ref<HTMLElement | null>(null)
|
||||||
|
const {
|
||||||
|
currentPage: currentModelPage,
|
||||||
|
totalPages: totalModelPages,
|
||||||
|
shouldPaginate: shouldPaginateModels,
|
||||||
|
paginatedItems: paginatedModels,
|
||||||
|
} = useSmartPagination(sortedModels, modelsListRef)
|
||||||
|
|
||||||
// 复制模型 ID 到剪贴板
|
// 复制模型 ID 到剪贴板
|
||||||
async function copyModelId(modelId: string) {
|
async function copyModelId(modelId: string) {
|
||||||
await copyToClipboard(modelId)
|
await copyToClipboard(modelId)
|
||||||
|
|||||||
@@ -41,6 +41,90 @@ const headerRuleTypes = [
|
|||||||
{ type: 'remove', name: '删除', description: '删除指定的请求头' }
|
{ type: 'remove', name: '删除', description: '删除指定的请求头' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 请求体规则类型
|
||||||
|
const bodyRuleTypes = [
|
||||||
|
{ action: 'set', name: '覆写', description: '设置或覆盖指定路径的字段值' },
|
||||||
|
{ action: 'drop', name: '删除', description: '删除指定路径的字段' },
|
||||||
|
{ action: 'rename', name: '重命名', description: '将字段从一个路径移动到另一个路径' },
|
||||||
|
{ action: 'insert', name: '插入', description: '在数组的指定位置插入元素,位置留空则追加到末尾' },
|
||||||
|
{ action: 'regex_replace', name: '正则替换', description: '对字符串字段执行正则表达式替换' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 请求体规则示例
|
||||||
|
const bodyRuleExamples = [
|
||||||
|
{
|
||||||
|
title: '注入系统提示词',
|
||||||
|
description: '在 messages 数组开头插入一条 system 消息(index: 0)',
|
||||||
|
rule: `{
|
||||||
|
"action": "insert",
|
||||||
|
"path": "messages",
|
||||||
|
"index": 0,
|
||||||
|
"value": {
|
||||||
|
"role": "system",
|
||||||
|
"content": "你是一个专业助手"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '追加消息到末尾',
|
||||||
|
description: '不指定 index,自动追加到数组末尾',
|
||||||
|
rule: `{
|
||||||
|
"action": "insert",
|
||||||
|
"path": "messages",
|
||||||
|
"value": {
|
||||||
|
"role": "user",
|
||||||
|
"content": "请用中文回答"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设置自定义元数据',
|
||||||
|
description: '覆写嵌套字段,不存在时自动创建中间层级',
|
||||||
|
rule: `{
|
||||||
|
"action": "set",
|
||||||
|
"path": "metadata.source",
|
||||||
|
"value": "internal-app"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '删除不需要的字段',
|
||||||
|
description: '移除请求体中的敏感或多余字段',
|
||||||
|
rule: `{
|
||||||
|
"action": "drop",
|
||||||
|
"path": "user_info.ip_address"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容脱敏',
|
||||||
|
description: '用正则替换 messages 中的手机号',
|
||||||
|
rule: `{
|
||||||
|
"action": "regex_replace",
|
||||||
|
"path": "messages[-1].content",
|
||||||
|
"pattern": "1[3-9]\\\\d{9}",
|
||||||
|
"replacement": "[手机号已隐藏]",
|
||||||
|
"flags": ""
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '重命名字段',
|
||||||
|
description: '将字段从旧路径移动到新路径',
|
||||||
|
rule: `{
|
||||||
|
"action": "rename",
|
||||||
|
"from": "extra.custom_id",
|
||||||
|
"to": "metadata.trace_id"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 路径语法示例
|
||||||
|
const pathSyntaxExamples = [
|
||||||
|
{ path: 'metadata.user', desc: '嵌套 dict 字段' },
|
||||||
|
{ path: 'messages[0].content', desc: '数组第一个元素的 content 字段' },
|
||||||
|
{ path: 'messages[-1]', desc: '数组最后一个元素' },
|
||||||
|
{ path: 'matrix[0][1]', desc: '多维数组访问' },
|
||||||
|
{ path: 'config\\.v1.enabled', desc: '\\. 转义为字面量点号 → key "config.v1"' },
|
||||||
|
]
|
||||||
|
|
||||||
// 系统设置分类
|
// 系统设置分类
|
||||||
const systemSettings = [
|
const systemSettings = [
|
||||||
{
|
{
|
||||||
@@ -273,6 +357,271 @@ const systemSettings = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 请求体规则 -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
请求体规则
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 概述 -->
|
||||||
|
<div
|
||||||
|
class="p-5"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 rounded-lg bg-orange-500/10">
|
||||||
|
<FileCode class="h-5 w-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
什么是请求体规则?
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||||
|
请求体规则允许你在转发请求时修改请求体(JSON Body)的内容。可以覆写字段、删除字段、向数组追加/插入元素,甚至用正则替换字符串值。
|
||||||
|
规则按顺序依次执行,受保护的顶层字段(<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">model</code>、<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">stream</code>)不可修改。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 操作类型表格 -->
|
||||||
|
<div
|
||||||
|
class="overflow-hidden"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
说明
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="rule in bodyRuleTypes"
|
||||||
|
:key="rule.action"
|
||||||
|
class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] last:border-0"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
<span :class="panelClasses.badge">{{ rule.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
{{ rule.description }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 路径语法 -->
|
||||||
|
<div
|
||||||
|
class="p-5 space-y-4"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
路径语法
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||||
|
使用点号(<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">.</code>)分隔层级,方括号(<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">[N]</code>)访问数组元素。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="overflow-hidden"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
路径
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
说明
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="ex in pathSyntaxExamples"
|
||||||
|
:key="ex.path"
|
||||||
|
class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] last:border-0"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
{{ ex.path }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
{{ ex.desc }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 实战示例 -->
|
||||||
|
<div
|
||||||
|
class="p-5 space-y-4"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
实战示例
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="example in bodyRuleExamples"
|
||||||
|
:key="example.title"
|
||||||
|
class="rounded-lg border border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-2.5 border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||||
|
<p class="font-medium text-sm text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
{{ example.title }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-[#666663] dark:text-[#a3a094] mt-0.5">
|
||||||
|
{{ example.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 text-xs font-mono text-[#262624] dark:text-[#f1ead8] overflow-x-auto bg-[#fafaf7]/30 dark:bg-[#1a1816]/30"><code>{{ example.rule }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 正则替换说明 -->
|
||||||
|
<div
|
||||||
|
class="p-5 space-y-4"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
正则替换详解
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||||
|
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">regex_replace</code> 对指定路径的字符串值执行正则表达式替换。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="overflow-hidden"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
参数
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
必填
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||||
|
说明
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
path
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Check class="h-4 w-4 text-green-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
目标字符串字段的路径
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
pattern
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Check class="h-4 w-4 text-green-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
正则表达式(Python re 语法),保存时会校验合法性
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
replacement
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Check class="h-4 w-4 text-green-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
替换字符串,留空则删除匹配内容
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
flags
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-[#999]">
|
||||||
|
可选
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">i</code> 忽略大小写 /
|
||||||
|
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">m</code> 多行模式 /
|
||||||
|
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">s</code> dotall(. 匹配换行)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="last:border-0">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
count
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-[#999]">
|
||||||
|
可选
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
|
||||||
|
替换次数,默认 0 = 全部替换
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 使用场景 -->
|
||||||
|
<div
|
||||||
|
class="p-4"
|
||||||
|
:class="[panelClasses.section]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||||
|
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
典型使用场景
|
||||||
|
</p>
|
||||||
|
<ul class="mt-1 space-y-1">
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">注入 System Prompt</span> — 在所有请求前插入统一的系统提示词
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">请求增强</span> — 自动追加上下文、metadata 等字段
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">内容过滤</span> — 用正则替换脱敏敏感信息(手机号、邮箱等)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">字段清理</span> — 删除不需要的自定义字段,避免上游报错
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">字段适配</span> — 将客户端的字段名重命名为上游期望的格式
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 代理设置 -->
|
<!-- 代理设置 -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">
|
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ class CompleteOAuthResponse(BaseModel):
|
|||||||
class ProviderCompleteOAuthRequest(BaseModel):
|
class ProviderCompleteOAuthRequest(BaseModel):
|
||||||
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
|
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
|
||||||
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
|
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
|
||||||
|
proxy_node_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="代理节点 ID(可选)。设置后 token 交换及后续所有操作(刷新、额度查询)均走该代理,避免 IP 污染",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProviderCompleteOAuthResponse(BaseModel):
|
class ProviderCompleteOAuthResponse(BaseModel):
|
||||||
@@ -162,6 +166,32 @@ def _require_fixed_provider(provider: Provider) -> str:
|
|||||||
return provider_type
|
return provider_type
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proxy_for_oauth(
|
||||||
|
provider_proxy: dict[str, Any] | None,
|
||||||
|
proxy_node_id: str | None,
|
||||||
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||||
|
"""解析 OAuth 操作使用的代理配置。
|
||||||
|
|
||||||
|
当前端指定了 proxy_node_id 时,优先使用该代理进行 token 交换等操作,
|
||||||
|
并返回需要保存到 Key 上的代理配置。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider_proxy: Provider 级别的代理配置
|
||||||
|
proxy_node_id: 前端指定的代理节点 ID(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(effective_proxy, key_proxy):
|
||||||
|
- effective_proxy: 本次操作实际使用的代理配置
|
||||||
|
- key_proxy: 需要保存到 Key 上的代理配置(None 表示不设置 Key 级代理)
|
||||||
|
"""
|
||||||
|
if proxy_node_id and proxy_node_id.strip():
|
||||||
|
key_proxy: dict[str, Any] = {"node_id": proxy_node_id.strip(), "enabled": True}
|
||||||
|
# 本次操作使用 Key 级代理
|
||||||
|
return key_proxy, key_proxy
|
||||||
|
# 无 Key 级代理,使用 Provider 级代理
|
||||||
|
return provider_proxy, None
|
||||||
|
|
||||||
|
|
||||||
def _pkce_s256(verifier: str) -> str:
|
def _pkce_s256(verifier: str) -> str:
|
||||||
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
|
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
|
||||||
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
|
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
|
||||||
@@ -207,11 +237,14 @@ def _create_oauth_key(
|
|||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
api_formats: list[str],
|
api_formats: list[str],
|
||||||
flush_only: bool = False,
|
flush_only: bool = False,
|
||||||
|
proxy: dict[str, Any] | None = None,
|
||||||
) -> "ProviderAPIKey":
|
) -> "ProviderAPIKey":
|
||||||
"""创建 OAuth Key 记录并持久化。
|
"""创建 OAuth Key 记录并持久化。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
flush_only: True 时仅 flush(批量导入场景),False 时 commit + refresh。
|
flush_only: True 时仅 flush(批量导入场景),False 时 commit + refresh。
|
||||||
|
proxy: Key 级别代理配置(如 {"node_id": "xxx", "enabled": True}),
|
||||||
|
创建时设置后,后续 token 刷新、额度刷新等操作立即走代理,避免 IP 污染。
|
||||||
"""
|
"""
|
||||||
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
|
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
|
||||||
|
|
||||||
@@ -224,6 +257,8 @@ def _create_oauth_key(
|
|||||||
api_formats=api_formats,
|
api_formats=api_formats,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
if proxy:
|
||||||
|
new_key.proxy = proxy
|
||||||
db.add(new_key)
|
db.add(new_key)
|
||||||
if flush_only:
|
if flush_only:
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -929,7 +964,10 @@ async def complete_provider_oauth(
|
|||||||
data = form
|
data = form
|
||||||
json_body = None
|
json_body = None
|
||||||
|
|
||||||
proxy_config = getattr(provider, "proxy", None)
|
# 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
|
||||||
|
proxy_config, key_proxy = _resolve_proxy_for_oauth(
|
||||||
|
getattr(provider, "proxy", None), payload.proxy_node_id
|
||||||
|
)
|
||||||
|
|
||||||
resp = await post_oauth_token(
|
resp = await post_oauth_token(
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
@@ -991,6 +1029,7 @@ async def complete_provider_oauth(
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
api_formats=_get_provider_api_formats(provider),
|
api_formats=_get_provider_api_formats(provider),
|
||||||
|
proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProviderCompleteOAuthResponse(
|
return ProviderCompleteOAuthResponse(
|
||||||
@@ -1096,6 +1135,10 @@ def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
|
|||||||
class ImportRefreshTokenRequest(BaseModel):
|
class ImportRefreshTokenRequest(BaseModel):
|
||||||
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
|
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
|
||||||
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
|
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
|
||||||
|
proxy_node_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="代理节点 ID(可选)。设置后导入验证及后续所有操作均走该代理",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BatchImportRequest(BaseModel):
|
class BatchImportRequest(BaseModel):
|
||||||
@@ -1107,6 +1150,10 @@ class BatchImportRequest(BaseModel):
|
|||||||
max_length=500_000,
|
max_length=500_000,
|
||||||
description="凭据数据,支持多种格式:JSON 对象、JSON 数组、纯 Token(一行一个)",
|
description="凭据数据,支持多种格式:JSON 对象、JSON 数组、纯 Token(一行一个)",
|
||||||
)
|
)
|
||||||
|
proxy_node_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="代理节点 ID(可选)。设置后批量导入验证及后续所有操作均走该代理",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BatchImportResultItem(BaseModel):
|
class BatchImportResultItem(BaseModel):
|
||||||
@@ -1148,6 +1195,11 @@ async def import_refresh_token(
|
|||||||
raise NotFoundException("Provider 不存在", "provider")
|
raise NotFoundException("Provider 不存在", "provider")
|
||||||
provider_type = _require_fixed_provider(provider)
|
provider_type = _require_fixed_provider(provider)
|
||||||
|
|
||||||
|
# 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
|
||||||
|
proxy_config, key_proxy = _resolve_proxy_for_oauth(
|
||||||
|
getattr(provider, "proxy", None), payload.proxy_node_id
|
||||||
|
)
|
||||||
|
|
||||||
if provider_type == ProviderType.KIRO.value:
|
if provider_type == ProviderType.KIRO.value:
|
||||||
raw_import = payload.refresh_token.strip()
|
raw_import = payload.refresh_token.strip()
|
||||||
if not raw_import:
|
if not raw_import:
|
||||||
@@ -1173,7 +1225,6 @@ async def import_refresh_token(
|
|||||||
cfg = KiroAuthConfig.from_dict(raw_cfg)
|
cfg = KiroAuthConfig.from_dict(raw_cfg)
|
||||||
cfg.provider_type = ProviderType.KIRO.value
|
cfg.provider_type = ProviderType.KIRO.value
|
||||||
|
|
||||||
proxy_config = getattr(provider, "proxy", None)
|
|
||||||
try:
|
try:
|
||||||
access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
|
access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1192,6 +1243,7 @@ async def import_refresh_token(
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
auth_config=new_cfg.to_dict(),
|
auth_config=new_cfg.to_dict(),
|
||||||
api_formats=_get_provider_api_formats(provider),
|
api_formats=_get_provider_api_formats(provider),
|
||||||
|
proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProviderCompleteOAuthResponse(
|
return ProviderCompleteOAuthResponse(
|
||||||
@@ -1243,7 +1295,7 @@ async def import_refresh_token(
|
|||||||
data = form
|
data = form
|
||||||
json_body = None
|
json_body = None
|
||||||
|
|
||||||
proxy_config = getattr(provider, "proxy", None)
|
# proxy_config 和 key_proxy 已在上方 Kiro 分支之前统一解析
|
||||||
|
|
||||||
resp = await post_oauth_token(
|
resp = await post_oauth_token(
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
@@ -1312,6 +1364,7 @@ async def import_refresh_token(
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
api_formats=_get_provider_api_formats(provider),
|
api_formats=_get_provider_api_formats(provider),
|
||||||
|
proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProviderCompleteOAuthResponse(
|
return ProviderCompleteOAuthResponse(
|
||||||
@@ -1355,6 +1408,11 @@ async def batch_import_oauth(
|
|||||||
|
|
||||||
provider_type = _require_fixed_provider(provider)
|
provider_type = _require_fixed_provider(provider)
|
||||||
|
|
||||||
|
# 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
|
||||||
|
proxy_config, key_proxy = _resolve_proxy_for_oauth(
|
||||||
|
getattr(provider, "proxy", None), payload.proxy_node_id
|
||||||
|
)
|
||||||
|
|
||||||
# Kiro 使用专用逻辑
|
# Kiro 使用专用逻辑
|
||||||
if provider_type == ProviderType.KIRO.value:
|
if provider_type == ProviderType.KIRO.value:
|
||||||
return await _batch_import_kiro_internal(
|
return await _batch_import_kiro_internal(
|
||||||
@@ -1362,6 +1420,8 @@ async def batch_import_oauth(
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
raw_credentials=payload.credentials,
|
raw_credentials=payload.credentials,
|
||||||
db=db,
|
db=db,
|
||||||
|
proxy_config=proxy_config,
|
||||||
|
key_proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 标准 OAuth Provider(Codex、Antigravity、GeminiCli、ClaudeCode)
|
# 标准 OAuth Provider(Codex、Antigravity、GeminiCli、ClaudeCode)
|
||||||
@@ -1378,8 +1438,6 @@ async def batch_import_oauth(
|
|||||||
raise InvalidRequestException("未找到有效的 Token 数据")
|
raise InvalidRequestException("未找到有效的 Token 数据")
|
||||||
|
|
||||||
api_formats = _get_provider_api_formats(provider)
|
api_formats = _get_provider_api_formats(provider)
|
||||||
|
|
||||||
proxy_config = getattr(provider, "proxy", None)
|
|
||||||
token_url = template.oauth.token_url
|
token_url = template.oauth.token_url
|
||||||
is_json = "anthropic.com" in token_url
|
is_json = "anthropic.com" in token_url
|
||||||
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
|
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
|
||||||
@@ -1550,6 +1608,7 @@ async def batch_import_oauth(
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
api_formats=api_formats,
|
api_formats=api_formats,
|
||||||
flush_only=True,
|
flush_only=True,
|
||||||
|
proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
@@ -1599,8 +1658,15 @@ async def _batch_import_kiro_internal(
|
|||||||
provider: Provider,
|
provider: Provider,
|
||||||
raw_credentials: str,
|
raw_credentials: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
proxy_config: dict[str, Any] | None = None,
|
||||||
|
key_proxy: dict[str, Any] | None = None,
|
||||||
) -> BatchImportResponse:
|
) -> BatchImportResponse:
|
||||||
"""Kiro 批量导入内部实现(供通用端点调用)。"""
|
"""Kiro 批量导入内部实现(供通用端点调用)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_config: 本次操作使用的代理配置(已由调用方解析)
|
||||||
|
key_proxy: 需要保存到 Key 上的代理配置
|
||||||
|
"""
|
||||||
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
|
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
|
||||||
from src.services.provider.adapters.kiro.token_manager import refresh_access_token
|
from src.services.provider.adapters.kiro.token_manager import refresh_access_token
|
||||||
|
|
||||||
@@ -1611,8 +1677,6 @@ async def _batch_import_kiro_internal(
|
|||||||
|
|
||||||
api_formats = _get_provider_api_formats(provider)
|
api_formats = _get_provider_api_formats(provider)
|
||||||
|
|
||||||
proxy_config = getattr(provider, "proxy", None)
|
|
||||||
|
|
||||||
results: list[BatchImportResultItem] = []
|
results: list[BatchImportResultItem] = []
|
||||||
success_count = 0
|
success_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
@@ -1675,6 +1739,7 @@ async def _batch_import_kiro_internal(
|
|||||||
auth_config=new_cfg.to_dict(),
|
auth_config=new_cfg.to_dict(),
|
||||||
api_formats=api_formats,
|
api_formats=api_formats,
|
||||||
flush_only=True,
|
flush_only=True,
|
||||||
|
proxy=key_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -33,6 +34,7 @@ from src.core.api_format import (
|
|||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||||
|
from src.models.endpoint_models import parse_re_flags
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
||||||
@@ -215,55 +217,103 @@ def build_test_request_body(
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _parse_path(path: str) -> list[str]:
|
# 路径段类型:str 表示 dict key,int 表示数组索引
|
||||||
|
PathSegment = str | int
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_path(path: str) -> list[PathSegment]:
|
||||||
"""
|
"""
|
||||||
解析点号路径,支持转义(用 \\.
|
解析路径,支持点号分隔、转义和数组索引。
|
||||||
表示字面量点号)。
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
"metadata.user.name" -> ["metadata", "user", "name"]
|
"metadata.user.name" -> ["metadata", "user", "name"]
|
||||||
"config\\.v1.enabled" -> ["config.v1", "enabled"]
|
"config\\.v1.enabled" -> ["config.v1", "enabled"]
|
||||||
|
"messages[0].content" -> ["messages", 0, "content"]
|
||||||
|
"data[0].items[2].name" -> ["data", 0, "items", 2, "name"]
|
||||||
|
"messages[-1]" -> ["messages", -1]
|
||||||
|
"matrix[0][1]" -> ["matrix", 0, 1]
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
|
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
|
||||||
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
|
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
|
||||||
|
- 数组索引必须是整数(支持负数索引)。
|
||||||
"""
|
"""
|
||||||
raw = (path or "").strip()
|
raw = (path or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[PathSegment] = []
|
||||||
current: list[str] = []
|
current: list[str] = []
|
||||||
|
expect_key = True # 是否期望下一个片段是 dict key
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(raw):
|
while i < len(raw):
|
||||||
ch = raw[i]
|
ch = raw[i]
|
||||||
|
|
||||||
|
# 转义点号:\\.
|
||||||
if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".":
|
if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".":
|
||||||
current.append(".")
|
current.append(".")
|
||||||
|
expect_key = False
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 点号分隔符
|
||||||
if ch == ".":
|
if ch == ".":
|
||||||
if not current:
|
if current:
|
||||||
|
parts.append("".join(current))
|
||||||
|
current = []
|
||||||
|
elif expect_key:
|
||||||
|
# 空段(如 ".a" 或 "a..b")
|
||||||
return []
|
return []
|
||||||
parts.append("".join(current))
|
expect_key = True
|
||||||
current = []
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 数组索引:[N]
|
||||||
|
if ch == "[":
|
||||||
|
# 先将当前累积的 key 入栈
|
||||||
|
if current:
|
||||||
|
parts.append("".join(current))
|
||||||
|
current = []
|
||||||
|
|
||||||
|
# 查找闭合括号
|
||||||
|
j = i + 1
|
||||||
|
while j < len(raw) and raw[j] != "]":
|
||||||
|
j += 1
|
||||||
|
if j >= len(raw):
|
||||||
|
return [] # 未闭合的括号
|
||||||
|
|
||||||
|
index_str = raw[i + 1 : j].strip()
|
||||||
|
if not index_str:
|
||||||
|
return [] # 空索引
|
||||||
|
|
||||||
|
try:
|
||||||
|
idx = int(index_str)
|
||||||
|
except ValueError:
|
||||||
|
return [] # 非整数索引
|
||||||
|
|
||||||
|
parts.append(idx)
|
||||||
|
expect_key = False
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
|
||||||
current.append(ch)
|
current.append(ch)
|
||||||
|
expect_key = False
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if not current:
|
# 收尾:将剩余的 key 入栈
|
||||||
|
if current:
|
||||||
|
parts.append("".join(current))
|
||||||
|
elif expect_key:
|
||||||
|
# 尾部悬挂的点号(如 "a.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
parts.append("".join(current))
|
return parts if parts else []
|
||||||
return parts
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]:
|
def _get_nested_value(obj: Any, path: str) -> tuple[bool, Any]:
|
||||||
"""
|
"""
|
||||||
获取嵌套值
|
获取嵌套值,支持 dict 和 list 混合遍历
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(found, value) - found 为 True 时 value 有效
|
(found, value) - found 为 True 时 value 有效
|
||||||
@@ -273,43 +323,92 @@ def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]:
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
current: Any = obj
|
current: Any = obj
|
||||||
for key in parts:
|
for segment in parts:
|
||||||
if isinstance(current, dict) and key in current:
|
if isinstance(segment, int):
|
||||||
current = current[key]
|
if isinstance(current, list):
|
||||||
|
try:
|
||||||
|
current = current[segment]
|
||||||
|
except IndexError:
|
||||||
|
return False, None
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
else:
|
else:
|
||||||
return False, None
|
if isinstance(current, dict) and segment in current:
|
||||||
|
current = current[segment]
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
return True, current
|
return True, current
|
||||||
|
|
||||||
|
|
||||||
def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool:
|
def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool:
|
||||||
"""
|
"""
|
||||||
设置嵌套值,自动创建中间层级。
|
设置嵌套值,支持 dict 和 list 混合遍历。
|
||||||
|
|
||||||
当中间层存在但不是 dict 时,会覆盖为 dict 后继续写入(覆写语义)。
|
- dict 中间层:下一段为 str key 时自动创建(覆写语义);下一段为 int 时要求已存在 list。
|
||||||
|
- list 中间层:必须已存在且索引有效。
|
||||||
|
- list 元素赋值:要求索引在范围内。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True: 写入成功
|
True: 写入成功
|
||||||
False: 路径无效(空/含空段等)
|
False: 路径无效或结构不匹配
|
||||||
"""
|
"""
|
||||||
parts = _parse_path(path)
|
parts = _parse_path(path)
|
||||||
if not parts:
|
if not parts:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current: dict[str, Any] = obj
|
current: Any = obj
|
||||||
for key in parts[:-1]:
|
for i in range(len(parts) - 1):
|
||||||
next_val = current.get(key)
|
segment = parts[i]
|
||||||
if not isinstance(next_val, dict):
|
next_segment = parts[i + 1]
|
||||||
next_val = {}
|
|
||||||
current[key] = next_val
|
|
||||||
current = next_val
|
|
||||||
|
|
||||||
current[parts[-1]] = value
|
if isinstance(segment, int):
|
||||||
return True
|
# 遍历数组元素
|
||||||
|
if not isinstance(current, list):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
current = current[segment]
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# 遍历 dict key
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return False
|
||||||
|
child = current.get(segment)
|
||||||
|
|
||||||
|
if isinstance(next_segment, int):
|
||||||
|
# 下一段是数组索引 → child 必须已经是 list
|
||||||
|
if not isinstance(child, list):
|
||||||
|
return False
|
||||||
|
current = child
|
||||||
|
else:
|
||||||
|
# 下一段是 dict key → 自动创建 dict(覆写语义)
|
||||||
|
if not isinstance(child, dict):
|
||||||
|
child = {}
|
||||||
|
current[segment] = child
|
||||||
|
current = child
|
||||||
|
|
||||||
|
# 写入最终值
|
||||||
|
last = parts[-1]
|
||||||
|
if isinstance(last, int):
|
||||||
|
if not isinstance(current, list):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
current[last] = value
|
||||||
|
return True
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return False
|
||||||
|
current[last] = value
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
|
def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
删除嵌套值
|
删除嵌套值,支持 dict 和 list 混合遍历
|
||||||
|
|
||||||
|
对于 list 元素,使用 del 删除(会移动后续元素的索引)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True: 删除成功
|
True: 删除成功
|
||||||
@@ -320,23 +419,40 @@ def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
current: Any = obj
|
current: Any = obj
|
||||||
for key in parts[:-1]:
|
for segment in parts[:-1]:
|
||||||
if isinstance(current, dict) and key in current:
|
if isinstance(segment, int):
|
||||||
current = current[key]
|
if isinstance(current, list):
|
||||||
|
try:
|
||||||
|
current = current[segment]
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
return False
|
if isinstance(current, dict) and segment in current:
|
||||||
if not isinstance(current, dict):
|
current = current[segment]
|
||||||
return False
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
if isinstance(current, dict) and parts[-1] in current:
|
last = parts[-1]
|
||||||
del current[parts[-1]]
|
if isinstance(last, int):
|
||||||
return True
|
if isinstance(current, list):
|
||||||
return False
|
try:
|
||||||
|
del current[last]
|
||||||
|
return True
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if isinstance(current, dict) and last in current:
|
||||||
|
del current[last]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool:
|
def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
重命名嵌套值(移动到新路径)
|
重命名嵌套值(移动到新路径),支持 dict 和 list 混合遍历
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True: 重命名成功
|
True: 重命名成功
|
||||||
@@ -354,11 +470,39 @@ def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> b
|
|||||||
if not found:
|
if not found:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 先 set 再 delete,避免 set 失败时源值已被删除导致数据丢失
|
||||||
|
if not _set_nested_value(obj, dst, value):
|
||||||
|
return False
|
||||||
_delete_nested_value(obj, src)
|
_delete_nested_value(obj, src)
|
||||||
_set_nested_value(obj, dst, value)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protected_path(parts: list[PathSegment], protected_lower: frozenset[str]) -> bool:
|
||||||
|
"""检查路径的顶层 key 是否为受保护字段(int 索引不可能是受保护字段)"""
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
first = parts[0]
|
||||||
|
return isinstance(first, str) and first.lower() in protected_lower
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_path(
|
||||||
|
rule: dict[str, Any],
|
||||||
|
protected_lower: frozenset[str],
|
||||||
|
key: str = "path",
|
||||||
|
) -> str | None:
|
||||||
|
"""从规则中提取并校验 path 字段,返回 strip 后的路径或 None(无效/受保护时)。"""
|
||||||
|
raw = rule.get(key, "")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
path = raw.strip()
|
||||||
|
parts = _parse_path(path)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
if _is_protected_path(parts, protected_lower):
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def apply_body_rules(
|
def apply_body_rules(
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
rules: list[dict[str, Any]],
|
rules: list[dict[str, Any]],
|
||||||
@@ -370,11 +514,19 @@ def apply_body_rules(
|
|||||||
路径语法:
|
路径语法:
|
||||||
- 使用点号分隔层级:metadata.user.name
|
- 使用点号分隔层级:metadata.user.name
|
||||||
- 转义字面量点号:config\\.v1.enabled -> key "config.v1" 下的 "enabled"
|
- 转义字面量点号:config\\.v1.enabled -> key "config.v1" 下的 "enabled"
|
||||||
|
- 使用方括号访问数组元素:messages[0].content
|
||||||
|
- 支持多层嵌套:data[0].items[2].name
|
||||||
|
- 支持负数索引:messages[-1]
|
||||||
|
- 支持连续数组索引:matrix[0][1]
|
||||||
|
|
||||||
支持的规则类型:
|
支持的规则类型:
|
||||||
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
||||||
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
||||||
|
- append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
||||||
|
- insert: 在数组指定位置插入元素 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
|
||||||
|
- regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "messages[0].content",
|
||||||
|
"pattern": "\\bfoo\\b", "replacement": "bar", "flags": "i", "count": 0}
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body: 原始请求体
|
body: 原始请求体
|
||||||
@@ -387,7 +539,7 @@ def apply_body_rules(
|
|||||||
if not rules:
|
if not rules:
|
||||||
return body
|
return body
|
||||||
|
|
||||||
# 深拷贝,避免修改原始数据(尤其是嵌套 dict)
|
# 深拷贝,避免修改原始数据(尤其是嵌套 dict/list)
|
||||||
result = copy.deepcopy(body)
|
result = copy.deepcopy(body)
|
||||||
protected = protected_keys or PROTECTED_BODY_FIELDS
|
protected = protected_keys or PROTECTED_BODY_FIELDS
|
||||||
protected_lower = frozenset(str(k).lower() for k in protected)
|
protected_lower = frozenset(str(k).lower() for k in protected)
|
||||||
@@ -402,27 +554,14 @@ def apply_body_rules(
|
|||||||
action = action.strip().lower()
|
action = action.strip().lower()
|
||||||
|
|
||||||
if action == "set":
|
if action == "set":
|
||||||
raw_path = rule.get("path", "")
|
path = _extract_path(rule, protected_lower)
|
||||||
if not isinstance(raw_path, str):
|
if not path:
|
||||||
continue
|
continue
|
||||||
path = raw_path.strip()
|
_set_nested_value(result, path, rule.get("value"))
|
||||||
value = rule.get("value")
|
|
||||||
parts = _parse_path(path)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
if parts[0].lower() in protected_lower:
|
|
||||||
continue
|
|
||||||
_set_nested_value(result, path, value)
|
|
||||||
|
|
||||||
elif action == "drop":
|
elif action == "drop":
|
||||||
raw_path = rule.get("path", "")
|
path = _extract_path(rule, protected_lower)
|
||||||
if not isinstance(raw_path, str):
|
if not path:
|
||||||
continue
|
|
||||||
path = raw_path.strip()
|
|
||||||
parts = _parse_path(path)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
if parts[0].lower() in protected_lower:
|
|
||||||
continue
|
continue
|
||||||
_delete_nested_value(result, path)
|
_delete_nested_value(result, path)
|
||||||
|
|
||||||
@@ -441,11 +580,62 @@ def apply_body_rules(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 受保护字段只检查顶层 key
|
# 受保护字段只检查顶层 key
|
||||||
if from_parts[0].lower() in protected_lower or to_parts[0].lower() in protected_lower:
|
if _is_protected_path(from_parts, protected_lower) or _is_protected_path(
|
||||||
|
to_parts, protected_lower
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_rename_nested_value(result, from_path, to_path)
|
_rename_nested_value(result, from_path, to_path)
|
||||||
|
|
||||||
|
elif action == "append":
|
||||||
|
path = _extract_path(rule, protected_lower)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
found, target = _get_nested_value(result, path)
|
||||||
|
if not found or not isinstance(target, list):
|
||||||
|
continue
|
||||||
|
target.append(rule.get("value"))
|
||||||
|
|
||||||
|
elif action == "insert":
|
||||||
|
path = _extract_path(rule, protected_lower)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
index = rule.get("index")
|
||||||
|
if not isinstance(index, int):
|
||||||
|
continue
|
||||||
|
found, target = _get_nested_value(result, path)
|
||||||
|
if not found or not isinstance(target, list):
|
||||||
|
continue
|
||||||
|
target.insert(index, rule.get("value"))
|
||||||
|
|
||||||
|
elif action == "regex_replace":
|
||||||
|
path = _extract_path(rule, protected_lower)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
pattern = rule.get("pattern")
|
||||||
|
replacement = rule.get("replacement", "")
|
||||||
|
if not isinstance(pattern, str) or not isinstance(replacement, str):
|
||||||
|
continue
|
||||||
|
if not pattern:
|
||||||
|
continue
|
||||||
|
|
||||||
|
flags_raw = rule.get("flags", "")
|
||||||
|
re_flags = parse_re_flags(flags_raw if isinstance(flags_raw, str) else "")
|
||||||
|
|
||||||
|
count = rule.get("count", 0)
|
||||||
|
if not isinstance(count, int) or count < 0:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
found, current_val = _get_nested_value(result, path)
|
||||||
|
if not found or not isinstance(current_val, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_val = re.compile(pattern, re_flags).sub(replacement, current_val, count=count)
|
||||||
|
_set_nested_value(result, path, new_val)
|
||||||
|
except re.error:
|
||||||
|
continue # 正则表达式无效,跳过
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,123 @@ HeaderRule = dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
# ========== Body Rule 类型定义 ==========
|
# ========== Body Rule 类型定义 ==========
|
||||||
# 请求体规则支持三种操作:
|
# 请求体规则支持六种操作:
|
||||||
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
# 实际验证在 request_builder.py 的 apply_body_rules 中处理
|
# - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
|
||||||
|
# - insert: 在数组指定位置插入 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
|
||||||
|
# - regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "...", "pattern": "...", "replacement": "..."}
|
||||||
|
# 路径语法支持数组索引:messages[0].content, data[-1], matrix[0][1]
|
||||||
|
# 运行时处理在 request_builder.py 的 apply_body_rules 中;结构校验见 _validate_body_rules
|
||||||
BodyRule = dict[str, Any]
|
BodyRule = dict[str, Any]
|
||||||
|
|
||||||
|
# body_rules 允许的 action 集合
|
||||||
|
_BODY_RULE_ACTIONS: frozenset[str] = frozenset(
|
||||||
|
{"set", "drop", "rename", "append", "insert", "regex_replace"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# regex_replace 允许的 flags 字符
|
||||||
|
_REGEX_FLAG_CHARS: frozenset[str] = frozenset({"i", "m", "s"})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_re_flags(flags_str: str) -> int:
|
||||||
|
"""将 flags 字符串(i/m/s)转换为 re 标志位。
|
||||||
|
|
||||||
|
供 endpoint_models 校验和 request_builder 运行时共用。
|
||||||
|
"""
|
||||||
|
result = 0
|
||||||
|
for f in flags_str:
|
||||||
|
if f == "i":
|
||||||
|
result |= re.IGNORECASE
|
||||||
|
elif f == "m":
|
||||||
|
result |= re.MULTILINE
|
||||||
|
elif f == "s":
|
||||||
|
result |= re.DOTALL
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
|
||||||
|
"""校验 body_rules 列表的结构和正则合法性。
|
||||||
|
|
||||||
|
校验项:
|
||||||
|
- 每条规则必须是 dict 且包含合法 action
|
||||||
|
- 需要 path 的 action(set/drop/append/insert/regex_replace)必须提供非空 path 字符串
|
||||||
|
- rename 必须提供非空 from / to 字符串
|
||||||
|
- insert 的 index 必须为整数
|
||||||
|
- regex_replace 的 pattern 必须能通过 re.compile 编译,flags 仅允许 i/m/s
|
||||||
|
"""
|
||||||
|
for idx, rule in enumerate(rules):
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象")
|
||||||
|
|
||||||
|
action = rule.get("action")
|
||||||
|
if not isinstance(action, str) or action.strip().lower() not in _BODY_RULE_ACTIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一,"
|
||||||
|
f"当前值: {action!r}"
|
||||||
|
)
|
||||||
|
action = action.strip().lower()
|
||||||
|
|
||||||
|
# ---------- path 校验 ----------
|
||||||
|
if action in {"set", "drop", "append", "insert", "regex_replace"}:
|
||||||
|
path = rule.get("path")
|
||||||
|
if not isinstance(path, str) or not path.strip():
|
||||||
|
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
|
||||||
|
|
||||||
|
# ---------- rename 校验 ----------
|
||||||
|
if action == "rename":
|
||||||
|
from_val = rule.get("from")
|
||||||
|
to_val = rule.get("to")
|
||||||
|
if not isinstance(from_val, str) or not from_val.strip():
|
||||||
|
raise ValueError(f"body_rules[{idx}]: rename 必须提供非空 from")
|
||||||
|
if not isinstance(to_val, str) or not to_val.strip():
|
||||||
|
raise ValueError(f"body_rules[{idx}]: rename 必须提供非空 to")
|
||||||
|
|
||||||
|
# ---------- insert 校验 ----------
|
||||||
|
if action == "insert":
|
||||||
|
index = rule.get("index")
|
||||||
|
if not isinstance(index, int) or isinstance(index, bool):
|
||||||
|
raise ValueError(f"body_rules[{idx}]: insert 的 index 必须为整数")
|
||||||
|
|
||||||
|
# ---------- regex_replace 校验 ----------
|
||||||
|
if action == "regex_replace":
|
||||||
|
pattern = rule.get("pattern")
|
||||||
|
if not isinstance(pattern, str) or not pattern:
|
||||||
|
raise ValueError(f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串")
|
||||||
|
|
||||||
|
replacement = rule.get("replacement", "")
|
||||||
|
if not isinstance(replacement, str):
|
||||||
|
raise ValueError(f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串")
|
||||||
|
|
||||||
|
# 校验 flags
|
||||||
|
flags_str = rule.get("flags", "")
|
||||||
|
re_flags = 0
|
||||||
|
if isinstance(flags_str, str) and flags_str:
|
||||||
|
invalid_flags = set(flags_str) - _REGEX_FLAG_CHARS
|
||||||
|
if invalid_flags:
|
||||||
|
raise ValueError(
|
||||||
|
f"body_rules[{idx}]: regex_replace 的 flags 仅允许 "
|
||||||
|
f"{''.join(sorted(_REGEX_FLAG_CHARS))},"
|
||||||
|
f"非法字符: {''.join(sorted(invalid_flags))}"
|
||||||
|
)
|
||||||
|
re_flags = parse_re_flags(flags_str)
|
||||||
|
|
||||||
|
# 尝试编译正则,捕获语法错误
|
||||||
|
try:
|
||||||
|
re.compile(pattern, re_flags)
|
||||||
|
except re.error as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"body_rules[{idx}]: regex_replace 的 pattern 不是合法正则表达式: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 校验 count
|
||||||
|
count = rule.get("count", 0)
|
||||||
|
if not isinstance(count, int) or count < 0:
|
||||||
|
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
# ========== ProviderEndpoint CRUD ==========
|
# ========== ProviderEndpoint CRUD ==========
|
||||||
|
|
||||||
@@ -55,7 +165,7 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
# 请求体配置
|
# 请求体配置
|
||||||
body_rules: list[BodyRule] | None = Field(
|
body_rules: list[BodyRule] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="请求体规则列表,支持 set/drop/rename 操作",
|
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
|
||||||
)
|
)
|
||||||
|
|
||||||
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
|
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
|
||||||
@@ -93,6 +203,14 @@ class ProviderEndpointCreate(BaseModel):
|
|||||||
|
|
||||||
return v.rstrip("/") # 移除末尾斜杠
|
return v.rstrip("/") # 移除末尾斜杠
|
||||||
|
|
||||||
|
@field_validator("body_rules")
|
||||||
|
@classmethod
|
||||||
|
def validate_body_rules(cls, v: list[BodyRule] | None) -> list[BodyRule] | None:
|
||||||
|
"""校验 body_rules 结构和正则合法性"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return _validate_body_rules(v)
|
||||||
|
|
||||||
|
|
||||||
class ProviderEndpointUpdate(BaseModel):
|
class ProviderEndpointUpdate(BaseModel):
|
||||||
"""更新 Endpoint 请求"""
|
"""更新 Endpoint 请求"""
|
||||||
@@ -111,7 +229,7 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
# 请求体配置
|
# 请求体配置
|
||||||
body_rules: list[BodyRule] | None = Field(
|
body_rules: list[BodyRule] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="请求体规则列表,支持 set/drop/rename 操作",
|
description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
|
||||||
)
|
)
|
||||||
|
|
||||||
max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数")
|
max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数")
|
||||||
@@ -137,6 +255,14 @@ class ProviderEndpointUpdate(BaseModel):
|
|||||||
|
|
||||||
return v.rstrip("/") # 移除末尾斜杠
|
return v.rstrip("/") # 移除末尾斜杠
|
||||||
|
|
||||||
|
@field_validator("body_rules")
|
||||||
|
@classmethod
|
||||||
|
def validate_body_rules(cls, v: list[BodyRule] | None) -> list[BodyRule] | None:
|
||||||
|
"""校验 body_rules 结构和正则合法性"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return _validate_body_rules(v)
|
||||||
|
|
||||||
|
|
||||||
class ProviderEndpointResponse(BaseModel):
|
class ProviderEndpointResponse(BaseModel):
|
||||||
"""Endpoint 响应"""
|
"""Endpoint 响应"""
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ def build_codex_url(
|
|||||||
async def enrich_codex(
|
async def enrich_codex(
|
||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
token_response: dict[str, Any],
|
token_response: dict[str, Any],
|
||||||
access_token: str,
|
access_token: str, # noqa: ARG001
|
||||||
proxy_config: dict[str, Any] | None,
|
proxy_config: dict[str, Any] | None, # noqa: ARG001
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
|
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
|
||||||
from src.core.provider_oauth_utils import parse_codex_id_token
|
from src.core.provider_oauth_utils import parse_codex_id_token
|
||||||
@@ -100,17 +100,11 @@ async def enrich_codex(
|
|||||||
bool(id_token),
|
bool(id_token),
|
||||||
list(token_response.keys()),
|
list(token_response.keys()),
|
||||||
)
|
)
|
||||||
codex_info = parse_codex_id_token(str(id_token) if id_token else None)
|
# parse_codex_id_token 仅返回非空有效字段,直接 update 即可
|
||||||
|
codex_info = parse_codex_id_token(id_token)
|
||||||
if codex_info:
|
if codex_info:
|
||||||
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
|
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
|
||||||
if codex_info.get("email"):
|
auth_config.update(codex_info)
|
||||||
auth_config["email"] = codex_info["email"]
|
|
||||||
if codex_info.get("account_id"):
|
|
||||||
auth_config["account_id"] = codex_info["account_id"]
|
|
||||||
if codex_info.get("plan_type"):
|
|
||||||
auth_config["plan_type"] = codex_info["plan_type"]
|
|
||||||
if codex_info.get("user_id"):
|
|
||||||
auth_config["user_id"] = codex_info["user_id"]
|
|
||||||
return auth_config
|
return auth_config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user