feat: 优化分组表单

This commit is contained in:
tbphp
2025-07-23 15:39:40 +08:00
parent f4774eb399
commit 5e909b34df
3 changed files with 487 additions and 123 deletions

View File

@@ -2,18 +2,18 @@
import { keysApi } from "@/api/keys"; import { keysApi } from "@/api/keys";
import { settingsApi } from "@/api/settings"; import { settingsApi } from "@/api/settings";
import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models"; import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models";
import { Add, Close, Remove } from "@vicons/ionicons5"; import { Add, Close, HelpCircleOutline, Remove } from "@vicons/ionicons5";
import { import {
NButton, NButton,
NCard, NCard,
NCollapse,
NCollapseItem,
NForm, NForm,
NFormItem, NFormItem,
NIcon,
NInput, NInput,
NInputNumber, NInputNumber,
NModal, NModal,
NSelect, NSelect,
NTooltip,
useMessage, useMessage,
type FormRules, type FormRules,
} from "naive-ui"; } from "naive-ui";
@@ -27,6 +27,7 @@ interface Props {
interface Emits { interface Emits {
(e: "update:show", value: boolean): void; (e: "update:show", value: boolean): void;
(e: "success", value: Group): void; (e: "success", value: Group): void;
(e: "switchToGroup", groupId: number): void;
} }
// 配置项类型 // 配置项类型
@@ -51,7 +52,7 @@ interface GroupFormData {
display_name: string; display_name: string;
description: string; description: string;
upstreams: UpstreamInfo[]; upstreams: UpstreamInfo[];
channel_type: "openai" | "gemini" | "anthropic"; channel_type: "anthropic" | "gemini" | "openai";
sort: number; sort: number;
test_model: string; test_model: string;
validation_endpoint: string; validation_endpoint: string;
@@ -89,11 +90,11 @@ const configOptionsFetched = ref(false);
const testModelPlaceholder = computed(() => { const testModelPlaceholder = computed(() => {
switch (formData.channel_type) { switch (formData.channel_type) {
case "openai": case "openai":
return "如:gpt-4.1-nano"; return "gpt-4.1-nano";
case "gemini": case "gemini":
return "如:gemini-2.0-flash-lite"; return "gemini-2.0-flash-lite";
case "anthropic": case "anthropic":
return "如:claude-3-haiku-20240307"; return "claude-3-haiku-20240307";
default: default:
return "请输入模型名称"; return "请输入模型名称";
} }
@@ -112,6 +113,19 @@ const upstreamPlaceholder = computed(() => {
} }
}); });
const validationEndpointPlaceholder = computed(() => {
switch (formData.channel_type) {
case "openai":
return "/v1/chat/completions";
case "anthropic":
return "/v1/messages";
case "gemini":
return ""; // Gemini 不显示此字段
default:
return "请输入验证端点路径";
}
});
// 表单验证规则 // 表单验证规则
const rules: FormRules = { const rules: FormRules = {
name: [ name: [
@@ -326,6 +340,10 @@ async function handleSubmit() {
} }
emit("success", res); emit("success", res);
// 如果是新建模式,发出切换到新分组的事件
if (!props.group?.id && res.id) {
emit("switchToGroup", res.id);
}
handleClose(); handleClose();
} finally { } finally {
loading.value = false; loading.value = false;
@@ -363,52 +381,152 @@ async function handleSubmit() {
<div class="form-section"> <div class="form-section">
<h4 class="section-title">基础信息</h4> <h4 class="section-title">基础信息</h4>
<n-form-item label="分组名称" path="name"> <!-- 分组名称和显示名称在同一行 -->
<n-input v-model:value="formData.name" placeholder="作为路由的一部分gemini" /> <div class="form-row">
</n-form-item> <n-form-item label="分组名称" path="name" class="form-item-half">
<template #label>
<div class="form-label-with-tooltip">
分组名称
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
作为API路由的一部分只能包含小写字母数字中划线或下划线长度3-30例如geminiopenai-2
</n-tooltip>
</div>
</template>
<n-input v-model:value="formData.name" placeholder="gemini" />
</n-form-item>
<n-form-item label="显示名称" path="display_name"> <n-form-item label="显示名称" path="display_name" class="form-item-half">
<n-input v-model:value="formData.display_name" placeholder="可选,用于显示的友好名称" /> <template #label>
</n-form-item> <div class="form-label-with-tooltip">
显示名称
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
用于在界面上显示的友好名称可以包含中文和特殊字符如果不填写将使用分组名称作为显示名称
</n-tooltip>
</div>
</template>
<n-input v-model:value="formData.display_name" placeholder="Google Gemini" />
</n-form-item>
</div>
<n-form-item label="渠道类型" path="channel_type"> <!-- 渠道类型和排序在同一行 -->
<n-select <div class="form-row">
v-model:value="formData.channel_type" <n-form-item label="渠道类型" path="channel_type" class="form-item-half">
:options="channelTypeOptions" <template #label>
placeholder="请选择渠道类型" <div class="form-label-with-tooltip">
/> 渠道类型
</n-form-item> <n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
选择API提供商类型决定了请求格式和认证方式支持OpenAIGeminiAnthropic等主流AI服务商
</n-tooltip>
</div>
</template>
<n-select
v-model:value="formData.channel_type"
:options="channelTypeOptions"
placeholder="请选择渠道类型"
/>
</n-form-item>
<n-form-item label="测试模型" path="test_model"> <n-form-item label="排序" path="sort" class="form-item-half">
<n-input v-model:value="formData.test_model" :placeholder="testModelPlaceholder" /> <template #label>
</n-form-item> <div class="form-label-with-tooltip">
排序
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
决定分组在列表中的显示顺序数字越小越靠前建议使用102030这样的间隔数字便于后续调整
</n-tooltip>
</div>
</template>
<n-input-number
v-model:value="formData.sort"
:min="0"
placeholder="排序值"
style="width: 100%"
/>
</n-form-item>
</div>
<n-form-item <!-- 测试模型和测试路径在同一行 -->
label="测试路径" <div class="form-row">
path="validation_endpoint" <n-form-item label="测试模型" path="test_model" class="form-item-half">
v-if="formData.channel_type !== 'gemini'" <template #label>
> <div class="form-label-with-tooltip">
<n-input 测试模型
v-model:value="formData.validation_endpoint" <n-tooltip trigger="hover" placement="top">
placeholder="可选自定义用于验证key的API路径" <template #trigger>
/> <n-icon :component="HelpCircleOutline" class="help-icon" />
</n-form-item> </template>
用于验证API密钥有效性的模型名称系统会使用这个模型发送测试请求来检查密钥是否可用
</n-tooltip>
</div>
</template>
<n-input v-model:value="formData.test_model" :placeholder="testModelPlaceholder" />
</n-form-item>
<n-form-item label="排序" path="sort"> <n-form-item
<n-input-number label="测试路径"
v-model:value="formData.sort" path="validation_endpoint"
:min="0" class="form-item-half"
placeholder="排序值,数字越小越靠前" v-if="formData.channel_type !== 'gemini'"
/> >
</n-form-item> <template #label>
<div class="form-label-with-tooltip">
测试路径
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
<div>
自定义用于验证密钥的API端点路径如果不填写将使用默认路径
<br />
OpenAI: /v1/chat/completions
<br />
Anthropic: /v1/messages
<br />
如需使用非标准路径请在此填写完整的API路径
</div>
</n-tooltip>
</div>
</template>
<n-input
v-model:value="formData.validation_endpoint"
:placeholder="validationEndpointPlaceholder || '可选自定义用于验证key的API路径'"
/>
</n-form-item>
<!-- 当gemini渠道时测试路径不显示需要一个占位div保持布局 -->
<div v-else class="form-item-half" />
</div>
<!-- 描述独占一行 -->
<n-form-item label="描述" path="description"> <n-form-item label="描述" path="description">
<template #label>
<div class="form-label-with-tooltip">
描述
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
分组的详细说明帮助团队成员了解该分组的用途和特点支持多行文本
</n-tooltip>
</div>
</template>
<n-input <n-input
v-model:value="formData.description" v-model:value="formData.description"
type="textarea" type="textarea"
placeholder="可选,分组描述信息" placeholder=""
:rows="2" :rows="1"
:autosize="{ minRows: 2, maxRows: 2 }" :autosize="{ minRows: 1, maxRows: 5 }"
style="resize: none" style="resize: none"
/> />
</n-form-item> </n-form-item>
@@ -417,7 +535,6 @@ async function handleSubmit() {
<!-- 上游地址 --> <!-- 上游地址 -->
<div class="form-section" style="margin-top: 10px"> <div class="form-section" style="margin-top: 10px">
<h4 class="section-title">上游地址</h4> <h4 class="section-title">上游地址</h4>
<n-form-item <n-form-item
v-for="(upstream, index) in formData.upstreams" v-for="(upstream, index) in formData.upstreams"
:key="index" :key="index"
@@ -429,27 +546,38 @@ async function handleSubmit() {
trigger: ['blur', 'input'], trigger: ['blur', 'input'],
}" }"
> >
<div class="flex items-center gap-2" style="width: 100%"> <template #label>
<n-input <div class="form-label-with-tooltip">
v-model:value="upstream.url" 上游 {{ index + 1 }}
:placeholder="upstreamPlaceholder" <n-tooltip trigger="hover" placement="top">
style="flex: 1" <template #trigger>
/> <n-icon :component="HelpCircleOutline" class="help-icon" />
<span class="form-label">权重</span> </template>
<n-input-number API服务器的完整URL地址多个上游可以实现负载均衡和故障转移提高服务可用性
v-model:value="upstream.weight" </n-tooltip>
:min="1" </div>
placeholder="权重" </template>
style="width: 100px" <div class="upstream-row">
/> <div class="upstream-url">
<div style="width: 40px"> <n-input v-model:value="upstream.url" :placeholder="upstreamPlaceholder" />
</div>
<div class="upstream-weight">
<span class="weight-label">权重</span>
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-input-number v-model:value="upstream.weight" :min="1" placeholder="权重" />
</template>
负载均衡权重数值越大被选中的概率越高例如权重为2的上游被选中的概率是权重为1的两倍
</n-tooltip>
</div>
<div class="upstream-actions">
<n-button <n-button
v-if="formData.upstreams.length > 1" v-if="formData.upstreams.length > 1"
@click="removeUpstream(index)" @click="removeUpstream(index)"
type="error" type="error"
quaternary quaternary
circle circle
style="margin-left: 10px" size="small"
> >
<template #icon> <template #icon>
<n-icon :component="Remove" /> <n-icon :component="Remove" />
@@ -472,15 +600,24 @@ async function handleSubmit() {
<!-- 高级配置 --> <!-- 高级配置 -->
<div class="form-section" style="margin-top: 10px"> <div class="form-section" style="margin-top: 10px">
<n-collapse> <n-collapse>
<n-collapse-item title="高级配置" name="advanced"> <n-collapse-item name="advanced">
<template #header>高级配置</template>
<div class="config-section"> <div class="config-section">
<h5 class="config-title">分组配置</h5> <h5 class="config-title-with-tooltip">
分组配置
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon config-help" />
</template>
针对此分组的专用配置参数如超时时间重试次数等这些配置会覆盖全局默认设置
</n-tooltip>
</h5>
<div class="config-items"> <div class="config-items">
<n-form-item <n-form-item
v-for="(configItem, index) in formData.configItems" v-for="(configItem, index) in formData.configItems"
:key="index" :key="index"
class="flex config-item" class="config-item-row"
:label="`配置 ${index + 1}`" :label="`配置 ${index + 1}`"
:path="`configItems[${index}].key`" :path="`configItems[${index}].key`"
:rule="{ :rule="{
@@ -489,42 +626,56 @@ async function handleSubmit() {
trigger: ['blur', 'change'], trigger: ['blur', 'change'],
}" }"
> >
<div class="flex items-center" style="width: 100%"> <template #label>
<n-select <div class="form-label-with-tooltip">
v-model:value="configItem.key" 配置 {{ index + 1 }}
:options=" <n-tooltip trigger="hover" placement="top">
configOptions.map(opt => ({ <template #trigger>
label: opt.name, <n-icon :component="HelpCircleOutline" class="help-icon" />
value: opt.key, </template>
disabled: 选择要配置的参数类型然后设置对应的数值不同参数有不同的作用和取值范围
formData.configItems </n-tooltip>
.map((item: ConfigItem) => item.key) </div>
?.includes(opt.key) && opt.key !== configItem.key, </template>
})) <div class="config-item-content">
" <div class="config-select">
placeholder="请选择配置参数" <n-select
style="min-width: 200px" v-model:value="configItem.key"
@update:value="value => handleConfigKeyChange(index, value)" :options="
clearable configOptions.map(opt => ({
/> label: opt.name,
<n-input-number value: opt.key,
v-model:value="configItem.value" disabled:
placeholder="参数值" formData.configItems
style="width: 180px; margin-left: 15px" .map((item: ConfigItem) => item.key)
:precision="0" ?.includes(opt.key) && opt.key !== configItem.key,
/> }))
<n-button "
@click="removeConfigItem(index)" placeholder="请选择配置参数"
type="error" @update:value="value => handleConfigKeyChange(index, value)"
quaternary clearable
circle />
size="small" </div>
style="margin-left: 10px" <div class="config-value">
> <n-input-number
<template #icon> v-model:value="configItem.value"
<n-icon :component="Remove" /> placeholder="参数值"
</template> :precision="0"
</n-button> />
</div>
<div class="config-actions">
<n-button
@click="removeConfigItem(index)"
type="error"
quaternary
circle
size="small"
>
<template #icon>
<n-icon :component="Remove" />
</template>
</n-button>
</div>
</div> </div>
</n-form-item> </n-form-item>
</div> </div>
@@ -544,17 +695,26 @@ async function handleSubmit() {
</div> </div>
</div> </div>
<div class="config-section"> <div class="config-section">
<h5 class="config-title">参数覆盖</h5> <n-form-item path="param_overrides">
<div class="config-items"> <template #label>
<n-form-item path="param_overrides"> <div class="form-label-with-tooltip">
<n-input 参数覆盖
v-model:value="formData.param_overrides" <n-tooltip trigger="hover" placement="top">
type="textarea" <template #trigger>
placeholder="JSON 格式的参数覆盖配置" <n-icon :component="HelpCircleOutline" class="help-icon config-help" />
:rows="4" </template>
/> 使用JSON格式定义要覆盖的API请求参数例如 {&quot;temperature&quot;: 0.7,
</n-form-item> &quot;max_tokens&quot;: 2000}这些参数会在发送请求时合并到原始参数中
</div> </n-tooltip>
</div>
</template>
<n-input
v-model:value="formData.param_overrides"
type="textarea"
placeholder='{"temperature": 0.7, "max_tokens": 2000}'
:rows="4"
/>
</n-form-item>
</div> </div>
</n-collapse-item> </n-collapse-item>
</n-collapse> </n-collapse>
@@ -649,10 +809,187 @@ async function handleSubmit() {
font-weight: 500; font-weight: 500;
} }
.config-item { /* Tooltip相关样式 */
margin-bottom: 12px; .form-label-with-tooltip {
display: flex;
align-items: center;
gap: 6px;
} }
.help-icon {
color: #9ca3af;
font-size: 14px;
cursor: help;
transition: color 0.2s ease;
}
.help-icon:hover {
color: #667eea;
}
.section-title-with-tooltip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.section-help {
font-size: 16px;
}
.collapse-header-with-tooltip {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.collapse-help {
font-size: 14px;
}
.config-title-with-tooltip {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
}
.config-help {
font-size: 13px;
}
/* 增强表单样式 */
:deep(.n-form-item-label) {
font-weight: 500;
color: #374151;
}
:deep(.n-input) {
--n-border-radius: 8px;
--n-border: 1px solid #e5e7eb;
--n-border-hover: 1px solid #667eea;
--n-border-focus: 1px solid #667eea;
--n-box-shadow-focus: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
:deep(.n-select) {
--n-border-radius: 8px;
}
:deep(.n-input-number) {
--n-border-radius: 8px;
}
:deep(.n-button) {
--n-border-radius: 8px;
}
/* 美化tooltip */
:deep(.n-tooltip__trigger) {
display: inline-flex;
align-items: center;
}
:deep(.n-tooltip) {
--n-font-size: 13px;
--n-border-radius: 8px;
}
:deep(.n-tooltip .n-tooltip__content) {
max-width: 320px;
line-height: 1.5;
}
:deep(.n-tooltip .n-tooltip__content div) {
white-space: pre-line;
}
/* 折叠面板样式优化 */
:deep(.n-collapse-item__header) {
font-weight: 500;
color: #374151;
}
:deep(.n-collapse-item) {
--n-title-padding: 16px 0;
}
:deep(.n-base-selection-label) { :deep(.n-base-selection-label) {
height: 40px; height: 40px;
} }
/* 表单行布局 */
.form-row {
display: flex;
gap: 20px;
align-items: flex-start;
}
.form-item-half {
flex: 1;
width: 50%;
}
/* 上游地址行布局 */
.upstream-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.upstream-url {
flex: 1;
}
.upstream-weight {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 140px;
}
.weight-label {
font-weight: 500;
color: #374151;
white-space: nowrap;
}
.upstream-actions {
flex: 0 0 32px;
display: flex;
justify-content: center;
}
/* 配置项行布局 */
.config-item-row {
margin-bottom: 12px;
}
.config-item-content {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.config-select {
flex: 1;
min-width: 200px;
}
.config-value {
flex: 0 0 140px;
}
.config-actions {
flex: 0 0 32px;
display: flex;
justify-content: center;
}
</style> </style>

View File

@@ -295,27 +295,27 @@ function resetPage() {
<n-grid :cols="2"> <n-grid :cols="2">
<n-grid-item> <n-grid-item>
<n-form-item label="分组名称:"> <n-form-item label="分组名称:">
{{ group?.name || "-" }} {{ group?.name }}
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-form-item label="显示名称:"> <n-form-item label="显示名称:">
{{ group?.display_name || "-" }} {{ group?.display_name }}
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-form-item label="渠道类型:"> <n-form-item label="渠道类型:">
{{ group?.channel_type || "-" }} {{ group?.channel_type }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="测试模型:">
{{ group?.test_model || "-" }}
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-form-item label="排序:"> <n-form-item label="排序:">
{{ group?.sort || 0 }} {{ group?.sort }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="测试模型:">
{{ group?.test_model }}
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item v-if="group?.channel_type !== 'gemini'"> <n-grid-item v-if="group?.channel_type !== 'gemini'">
@@ -325,7 +325,9 @@ function resetPage() {
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-form-item label="描述:"> <n-form-item label="描述:">
{{ group?.description || "-" }} <div class="description-content">
{{ group?.description }}
</div>
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -521,4 +523,13 @@ function resetPage() {
:deep(.n-form-item-feedback-wrapper) { :deep(.n-form-item-feedback-wrapper) {
min-height: 0; min-height: 0;
} }
/* 描述内容样式 */
.description-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
min-height: 20px;
color: #374151;
}
</style> </style>

View File

@@ -61,10 +61,22 @@ function openCreateGroupModal() {
showGroupModal.value = true; showGroupModal.value = true;
} }
function handleGroupCreated() { function handleGroupCreated(_group: Group) {
showGroupModal.value = false; showGroupModal.value = false;
emit("refresh"); emit("refresh");
} }
function handleSwitchToGroup(groupId: number) {
// 创建成功后,等待列表刷新完成,然后切换到新创建的分组
emit("refresh");
// 延迟选择新分组,确保列表已更新
setTimeout(() => {
const newGroup = props.groups.find(g => g.id === groupId);
if (newGroup) {
emit("group-select", newGroup);
}
}, 100);
}
</script> </script>
<template> <template>
@@ -123,7 +135,11 @@ function handleGroupCreated() {
</n-button> </n-button>
</div> </div>
</n-card> </n-card>
<group-form-modal v-model:show="showGroupModal" @success="handleGroupCreated" /> <group-form-modal
v-model:show="showGroupModal"
@success="handleGroupCreated"
@switch-to-group="handleSwitchToGroup"
/>
</div> </div>
</template> </template>