mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +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:
@@ -208,10 +208,12 @@ export interface BatchImportResult {
|
||||
|
||||
export async function batchImportOAuth(
|
||||
providerId: string,
|
||||
credentials: string
|
||||
credentials: string,
|
||||
proxyNodeId?: string
|
||||
): Promise<BatchImportResult> {
|
||||
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
|
||||
credentials,
|
||||
proxy_node_id: proxyNodeId || undefined,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ProviderOAuthStartResponse {
|
||||
export interface ProviderOAuthCompleteRequest {
|
||||
callback_url: string
|
||||
name?: string
|
||||
proxy_node_id?: string
|
||||
}
|
||||
|
||||
export interface ProviderOAuthCompleteResponse {
|
||||
@@ -49,7 +50,7 @@ export async function completeProviderLevelOAuth(
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: { refresh_token: string; name?: string }
|
||||
data: { refresh_token: string; name?: string; proxy_node_id?: string }
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
|
||||
@@ -159,7 +159,51 @@ export interface BodyRuleRename {
|
||||
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"
|
||||
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>
|
||||
|
||||
<!-- 请求体规则列表 - 次要色边框 -->
|
||||
@@ -312,10 +312,10 @@
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
: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)"
|
||||
>
|
||||
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
||||
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -328,6 +328,12 @@
|
||||
<SelectItem value="rename">
|
||||
重命名
|
||||
</SelectItem>
|
||||
<SelectItem value="insert">
|
||||
插入
|
||||
</SelectItem>
|
||||
<SelectItem value="regex_replace">
|
||||
正则替换
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<template v-if="rule.action === 'set'">
|
||||
@@ -378,6 +384,72 @@
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
|
||||
/>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -519,6 +591,7 @@ import {
|
||||
type ProviderWithEndpointsSummary,
|
||||
type HeaderRule,
|
||||
type BodyRule,
|
||||
type BodyRuleRegexReplace,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -532,12 +605,18 @@ interface EditableRule {
|
||||
}
|
||||
|
||||
// 编辑用的请求体规则类型
|
||||
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace'
|
||||
|
||||
interface EditableBodyRule {
|
||||
action: 'set' | 'drop' | 'rename'
|
||||
path: string // set/drop 用
|
||||
value: string // set 用(JSON 格式)
|
||||
action: BodyRuleAction
|
||||
path: string // set/drop/append/insert/regex_replace 用
|
||||
value: string // set/append/insert 用(JSON 格式)
|
||||
from: string // rename 用
|
||||
to: string // rename 用
|
||||
index: string // insert 用(字符串输入,保存时解析为 int)
|
||||
pattern: string // regex_replace 用
|
||||
replacement: string // regex_replace 用
|
||||
flags: string // regex_replace 用(i/m/s)
|
||||
}
|
||||
|
||||
// 端点编辑状态(仅 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[] = []
|
||||
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||
for (const rule of endpoint.body_rules) {
|
||||
if (rule.action === 'set') {
|
||||
const { value } = initBodyRuleSetValueForEditor((rule as any).value)
|
||||
bodyRules.push({ action: 'set', path: rule.path, value, from: '', to: '' })
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value })
|
||||
} 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') {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
if (rules[index]) {
|
||||
rules[index].action = action
|
||||
@@ -997,11 +1089,15 @@ function updateEndpointBodyRuleAction(endpointId: string, index: number, action:
|
||||
rules[index].value = ''
|
||||
rules[index].from = ''
|
||||
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)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
@@ -1013,11 +1109,14 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
||||
const raw = path.trim()
|
||||
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) {
|
||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||
return '路径格式无效'
|
||||
}
|
||||
|
||||
// 提取顶层 key(去除数组索引部分)
|
||||
const topKey = (parts[0] || '').trim().toLowerCase()
|
||||
if (RESERVED_BODY_FIELDS.has(topKey)) {
|
||||
return `"${parts[0]}" 是系统保留的顶层字段`
|
||||
@@ -1101,7 +1200,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
||||
}
|
||||
|
||||
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()
|
||||
if (!raw) return '值不能为空'
|
||||
@@ -1116,7 +1215,7 @@ function validateBodySetValue(rule: EditableBodyRule): string | null {
|
||||
|
||||
// 获取值验证状态:true=有效, false=无效, 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()
|
||||
if (!raw) return null
|
||||
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 {
|
||||
const validation = getBodySetValueValidation(rule)
|
||||
@@ -1151,6 +1285,8 @@ function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
|
||||
return state.bodyRules.filter(r => {
|
||||
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||
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
|
||||
}).length
|
||||
}
|
||||
@@ -1197,6 +1333,13 @@ function _formatBodyRuleLabel(rule: EditableBodyRule): string {
|
||||
} else if (rule.action === 'rename') {
|
||||
if (!rule.from || !rule.to) return '(未设置)'
|
||||
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 '(未知)'
|
||||
}
|
||||
@@ -1210,6 +1353,8 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
const editedRules = state.bodyRules.filter(r => {
|
||||
if (r.action === 'set' || r.action === 'drop') return r.path.trim()
|
||||
if (r.action === 'rename') return r.from.trim() && r.to.trim()
|
||||
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
|
||||
})
|
||||
if (editedRules.length !== originalRules.length) return true
|
||||
@@ -1219,13 +1364,29 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
if (!original) return true
|
||||
if (edited.action !== original.action) return true
|
||||
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.value !== baseline.value) return true
|
||||
} else if (edited.action === 'drop' && original.action === 'drop') {
|
||||
if (edited.path !== original.path) return true
|
||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||
if (edited.from !== original.from || edited.to !== original.to) return true
|
||||
} else if (edited.action === '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
|
||||
@@ -1238,17 +1399,33 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'set' && rule.path.trim()) {
|
||||
let value: any = rule.value
|
||||
try {
|
||||
value = JSON.parse(rule.value.trim())
|
||||
} catch {
|
||||
// 保存前会做校验;这里兜底避免 UI 崩溃
|
||||
value = rule.value
|
||||
}
|
||||
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
|
||||
result.push({ action: 'set', path: rule.path.trim(), value })
|
||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||
result.push({ action: 'drop', path: rule.path.trim() })
|
||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||
} 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}`
|
||||
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
|
||||
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
|
||||
|
||||
@@ -6,6 +6,58 @@
|
||||
size="md"
|
||||
@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">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
|
||||
@@ -227,8 +279,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink, Upload, Globe } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -238,6 +290,8 @@ import {
|
||||
importProviderRefreshToken,
|
||||
batchImportOAuth,
|
||||
} from '@/api/endpoints'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -252,6 +306,18 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
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'
|
||||
@@ -318,6 +384,8 @@ function resetForm() {
|
||||
importing.value = false
|
||||
isDragging.value = false
|
||||
showManualInput.value = false
|
||||
proxyPopoverOpen.value = false
|
||||
selectedProxyNodeId.value = ''
|
||||
mode.value = isKiroProvider.value ? 'import' : 'oauth'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
@@ -393,6 +461,7 @@ async function handleCompleteOAuth() {
|
||||
try {
|
||||
await completeProviderLevelOAuth(props.providerId, {
|
||||
callback_url: oauth.value.callback_url.trim(),
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
success('授权成功,账号已添加')
|
||||
emit('saved')
|
||||
@@ -498,10 +567,11 @@ async function handleImport() {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const proxyNodeId = selectedProxyNodeId.value || undefined
|
||||
// 检测是否为批量导入
|
||||
if (isBatchImport(inputText)) {
|
||||
// 批量导入
|
||||
const result = await batchImportOAuth(props.providerId, inputText)
|
||||
const result = await batchImportOAuth(props.providerId, inputText, proxyNodeId)
|
||||
if (result.success > 0) {
|
||||
if (result.failed > 0) {
|
||||
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||
@@ -522,7 +592,10 @@ async function handleImport() {
|
||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||
return
|
||||
}
|
||||
await importProviderRefreshToken(props.providerId, parsed)
|
||||
await importProviderRefreshToken(props.providerId, {
|
||||
...parsed,
|
||||
proxy_node_id: proxyNodeId,
|
||||
})
|
||||
success('导入成功,账号已添加')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
@@ -537,6 +610,8 @@ async function handleImport() {
|
||||
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
// 预加载代理节点列表
|
||||
proxyNodesStore.ensureLoaded()
|
||||
if (isKiroProvider.value) {
|
||||
mode.value = 'import'
|
||||
} else {
|
||||
|
||||
@@ -206,23 +206,24 @@
|
||||
<!-- 密钥列表 -->
|
||||
<div
|
||||
v-if="allKeys.length > 0"
|
||||
ref="keysListRef"
|
||||
class="divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="({ key, endpoint }, index) in allKeys"
|
||||
v-for="({ key, endpoint }, localIdx) in paginatedKeys"
|
||||
:key="key.id"
|
||||
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
||||
:class="{
|
||||
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index,
|
||||
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging,
|
||||
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === getGlobalKeyIndex(localIdx),
|
||||
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === getGlobalKeyIndex(localIdx) && keyDragState.isDragging,
|
||||
'opacity-40 bg-muted/20': !key.is_active
|
||||
}"
|
||||
draggable="true"
|
||||
@dragstart="handleKeyDragStart($event, index)"
|
||||
@dragstart="handleKeyDragStart($event, getGlobalKeyIndex(localIdx))"
|
||||
@dragend="handleKeyDragEnd"
|
||||
@dragover="handleKeyDragOver($event, index)"
|
||||
@dragover="handleKeyDragOver($event, getGlobalKeyIndex(localIdx))"
|
||||
@dragleave="handleKeyDragLeave"
|
||||
@drop="handleKeyDrop($event, index)"
|
||||
@drop="handleKeyDrop($event, getGlobalKeyIndex(localIdx))"
|
||||
>
|
||||
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@@ -760,6 +761,34 @@
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -900,6 +929,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import {
|
||||
Plus,
|
||||
Key,
|
||||
@@ -1104,6 +1134,17 @@ const allKeys = computed(() => {
|
||||
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 都触发导致重复请求
|
||||
watch(
|
||||
[() => props.providerId, () => props.open],
|
||||
@@ -1126,6 +1167,9 @@ watch(
|
||||
endpoints.value = []
|
||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||
|
||||
// 重置分页状态
|
||||
resetKeysPagination()
|
||||
|
||||
// 重置所有对话框状态
|
||||
endpointDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
|
||||
@@ -24,12 +24,6 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
暂无在线代理节点,请在「代理节点」页面添加
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -29,23 +29,24 @@
|
||||
<!-- 映射列表 -->
|
||||
<div
|
||||
v-else-if="combinedMappings.length > 0"
|
||||
ref="mappingsListRef"
|
||||
class="divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in combinedMappings"
|
||||
v-for="item in paginatedMappings"
|
||||
:key="item.key"
|
||||
class="transition-colors"
|
||||
>
|
||||
<!-- 行头部(可点击展开) -->
|
||||
<div
|
||||
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">
|
||||
<!-- 展开/收起图标 -->
|
||||
<ChevronRight
|
||||
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'">
|
||||
@@ -134,7 +135,7 @@
|
||||
|
||||
<!-- 展开的映射详情 -->
|
||||
<div
|
||||
v-show="expandedItems.has(index)"
|
||||
v-show="expandedItems.has(item.key)"
|
||||
class="bg-muted/30 border-t border-border/30"
|
||||
>
|
||||
<!-- 精确映射详情 -->
|
||||
@@ -272,6 +273,34 @@
|
||||
</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>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -315,6 +344,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||
import {
|
||||
Card, Button, Badge,
|
||||
@@ -393,7 +423,7 @@ const providerKeysState = ref<EndpointAPIKey[]>([])
|
||||
const formatMenuOpen = ref<Record<string, boolean>>({})
|
||||
|
||||
// 展开状态
|
||||
const expandedItems = ref<Set<number>>(new Set())
|
||||
const expandedItems = ref<Set<string>>(new Set())
|
||||
|
||||
// 是否有 key 配置了自动获取上游模型
|
||||
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() {
|
||||
try {
|
||||
@@ -554,11 +593,11 @@ const deleteConfirmDescription = computed(() => {
|
||||
})
|
||||
|
||||
// 切换展开状态
|
||||
function toggleExpand(index: number) {
|
||||
if (expandedItems.value.has(index)) {
|
||||
expandedItems.value.delete(index)
|
||||
function toggleExpand(key: string) {
|
||||
if (expandedItems.value.has(key)) {
|
||||
expandedItems.value.delete(key)
|
||||
} else {
|
||||
expandedItems.value.add(index)
|
||||
expandedItems.value.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
v-else-if="models.length > 0"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<table class="w-full text-sm table-fixed">
|
||||
<table
|
||||
ref="modelsListRef"
|
||||
class="w-full text-sm table-fixed"
|
||||
>
|
||||
<colgroup>
|
||||
<col class="w-[45%]">
|
||||
<col class="w-[30%]">
|
||||
@@ -39,7 +42,7 @@
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="model in sortedModels"
|
||||
v-for="model in paginatedModels"
|
||||
:key="model.id"
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
@@ -196,6 +199,34 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</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>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -216,6 +247,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.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 到剪贴板
|
||||
async function copyModelId(modelId: string) {
|
||||
await copyToClipboard(modelId)
|
||||
|
||||
@@ -41,6 +41,90 @@ const headerRuleTypes = [
|
||||
{ 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 = [
|
||||
{
|
||||
@@ -273,6 +357,271 @@ const systemSettings = [
|
||||
</div>
|
||||
</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">
|
||||
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
|
||||
Reference in New Issue
Block a user