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 { settingsApi } from "@/api/settings";
import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models";
import { Add, Close, Remove } from "@vicons/ionicons5";
import { Add, Close, HelpCircleOutline, Remove } from "@vicons/ionicons5";
import {
NButton,
NCard,
NCollapse,
NCollapseItem,
NForm,
NFormItem,
NIcon,
NInput,
NInputNumber,
NModal,
NSelect,
NTooltip,
useMessage,
type FormRules,
} from "naive-ui";
@@ -27,6 +27,7 @@ interface Props {
interface Emits {
(e: "update:show", value: boolean): void;
(e: "success", value: Group): void;
(e: "switchToGroup", groupId: number): void;
}
// 配置项类型
@@ -51,7 +52,7 @@ interface GroupFormData {
display_name: string;
description: string;
upstreams: UpstreamInfo[];
channel_type: "openai" | "gemini" | "anthropic";
channel_type: "anthropic" | "gemini" | "openai";
sort: number;
test_model: string;
validation_endpoint: string;
@@ -89,11 +90,11 @@ const configOptionsFetched = ref(false);
const testModelPlaceholder = computed(() => {
switch (formData.channel_type) {
case "openai":
return "如:gpt-4.1-nano";
return "gpt-4.1-nano";
case "gemini":
return "如:gemini-2.0-flash-lite";
return "gemini-2.0-flash-lite";
case "anthropic":
return "如:claude-3-haiku-20240307";
return "claude-3-haiku-20240307";
default:
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 = {
name: [
@@ -326,6 +340,10 @@ async function handleSubmit() {
}
emit("success", res);
// 如果是新建模式,发出切换到新分组的事件
if (!props.group?.id && res.id) {
emit("switchToGroup", res.id);
}
handleClose();
} finally {
loading.value = false;
@@ -363,52 +381,152 @@ async function handleSubmit() {
<div class="form-section">
<h4 class="section-title">基础信息</h4>
<n-form-item label="分组名称" path="name">
<n-input v-model:value="formData.name" placeholder="作为路由的一部分gemini" />
</n-form-item>
<!-- 分组名称和显示名称在同一行 -->
<div class="form-row">
<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-input v-model:value="formData.display_name" placeholder="可选,用于显示的友好名称" />
</n-form-item>
<n-form-item label="显示名称" path="display_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>
用于在界面上显示的友好名称可以包含中文和特殊字符如果不填写将使用分组名称作为显示名称
</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
v-model:value="formData.channel_type"
:options="channelTypeOptions"
placeholder="请选择渠道类型"
/>
</n-form-item>
<!-- 渠道类型和排序在同一行 -->
<div class="form-row">
<n-form-item label="渠道类型" path="channel_type" 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提供商类型决定了请求格式和认证方式支持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-input v-model:value="formData.test_model" :placeholder="testModelPlaceholder" />
</n-form-item>
<n-form-item label="排序" path="sort" 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>
决定分组在列表中的显示顺序数字越小越靠前建议使用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="测试路径"
path="validation_endpoint"
v-if="formData.channel_type !== 'gemini'"
>
<n-input
v-model:value="formData.validation_endpoint"
placeholder="可选自定义用于验证key的API路径"
/>
</n-form-item>
<!-- 测试模型和测试路径在同一行 -->
<div class="form-row">
<n-form-item label="测试模型" path="test_model" 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密钥有效性的模型名称系统会使用这个模型发送测试请求来检查密钥是否可用
</n-tooltip>
</div>
</template>
<n-input v-model:value="formData.test_model" :placeholder="testModelPlaceholder" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number
v-model:value="formData.sort"
:min="0"
placeholder="排序值,数字越小越靠前"
/>
</n-form-item>
<n-form-item
label="测试路径"
path="validation_endpoint"
class="form-item-half"
v-if="formData.channel_type !== 'gemini'"
>
<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">
<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
v-model:value="formData.description"
type="textarea"
placeholder="可选,分组描述信息"
:rows="2"
:autosize="{ minRows: 2, maxRows: 2 }"
placeholder=""
:rows="1"
:autosize="{ minRows: 1, maxRows: 5 }"
style="resize: none"
/>
</n-form-item>
@@ -417,7 +535,6 @@ async function handleSubmit() {
<!-- 上游地址 -->
<div class="form-section" style="margin-top: 10px">
<h4 class="section-title">上游地址</h4>
<n-form-item
v-for="(upstream, index) in formData.upstreams"
:key="index"
@@ -429,27 +546,38 @@ async function handleSubmit() {
trigger: ['blur', 'input'],
}"
>
<div class="flex items-center gap-2" style="width: 100%">
<n-input
v-model:value="upstream.url"
:placeholder="upstreamPlaceholder"
style="flex: 1"
/>
<span class="form-label">权重</span>
<n-input-number
v-model:value="upstream.weight"
:min="1"
placeholder="权重"
style="width: 100px"
/>
<div style="width: 40px">
<template #label>
<div class="form-label-with-tooltip">
上游 {{ index + 1 }}
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
API服务器的完整URL地址多个上游可以实现负载均衡和故障转移提高服务可用性
</n-tooltip>
</div>
</template>
<div class="upstream-row">
<div class="upstream-url">
<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
v-if="formData.upstreams.length > 1"
@click="removeUpstream(index)"
type="error"
quaternary
circle
style="margin-left: 10px"
size="small"
>
<template #icon>
<n-icon :component="Remove" />
@@ -472,15 +600,24 @@ async function handleSubmit() {
<!-- 高级配置 -->
<div class="form-section" style="margin-top: 10px">
<n-collapse>
<n-collapse-item title="高级配置" name="advanced">
<n-collapse-item name="advanced">
<template #header>高级配置</template>
<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">
<n-form-item
v-for="(configItem, index) in formData.configItems"
:key="index"
class="flex config-item"
class="config-item-row"
:label="`配置 ${index + 1}`"
:path="`configItems[${index}].key`"
:rule="{
@@ -489,42 +626,56 @@ async function handleSubmit() {
trigger: ['blur', 'change'],
}"
>
<div class="flex items-center" style="width: 100%">
<n-select
v-model:value="configItem.key"
:options="
configOptions.map(opt => ({
label: opt.name,
value: opt.key,
disabled:
formData.configItems
.map((item: ConfigItem) => item.key)
?.includes(opt.key) && opt.key !== configItem.key,
}))
"
placeholder="请选择配置参数"
style="min-width: 200px"
@update:value="value => handleConfigKeyChange(index, value)"
clearable
/>
<n-input-number
v-model:value="configItem.value"
placeholder="参数值"
style="width: 180px; margin-left: 15px"
:precision="0"
/>
<n-button
@click="removeConfigItem(index)"
type="error"
quaternary
circle
size="small"
style="margin-left: 10px"
>
<template #icon>
<n-icon :component="Remove" />
</template>
</n-button>
<template #label>
<div class="form-label-with-tooltip">
配置 {{ index + 1 }}
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon" />
</template>
选择要配置的参数类型然后设置对应的数值不同参数有不同的作用和取值范围
</n-tooltip>
</div>
</template>
<div class="config-item-content">
<div class="config-select">
<n-select
v-model:value="configItem.key"
:options="
configOptions.map(opt => ({
label: opt.name,
value: opt.key,
disabled:
formData.configItems
.map((item: ConfigItem) => item.key)
?.includes(opt.key) && opt.key !== configItem.key,
}))
"
placeholder="请选择配置参数"
@update:value="value => handleConfigKeyChange(index, value)"
clearable
/>
</div>
<div class="config-value">
<n-input-number
v-model:value="configItem.value"
placeholder="参数值"
:precision="0"
/>
</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>
</n-form-item>
</div>
@@ -544,17 +695,26 @@ async function handleSubmit() {
</div>
</div>
<div class="config-section">
<h5 class="config-title">参数覆盖</h5>
<div class="config-items">
<n-form-item path="param_overrides">
<n-input
v-model:value="formData.param_overrides"
type="textarea"
placeholder="JSON 格式的参数覆盖配置"
:rows="4"
/>
</n-form-item>
</div>
<n-form-item path="param_overrides">
<template #label>
<div class="form-label-with-tooltip">
参数覆盖
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-icon :component="HelpCircleOutline" class="help-icon config-help" />
</template>
使用JSON格式定义要覆盖的API请求参数例如 {&quot;temperature&quot;: 0.7,
&quot;max_tokens&quot;: 2000}这些参数会在发送请求时合并到原始参数中
</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>
</n-collapse-item>
</n-collapse>
@@ -649,10 +809,187 @@ async function handleSubmit() {
font-weight: 500;
}
.config-item {
margin-bottom: 12px;
/* Tooltip相关样式 */
.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) {
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>

View File

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

View File

@@ -61,10 +61,22 @@ function openCreateGroupModal() {
showGroupModal.value = true;
}
function handleGroupCreated() {
function handleGroupCreated(_group: Group) {
showGroupModal.value = false;
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>
<template>
@@ -123,7 +135,11 @@ function handleGroupCreated() {
</n-button>
</div>
</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>
</template>