Files
gpt-load/web/src/components/keys/GroupFormModal.vue
2025-07-23 19:05:09 +08:00

1084 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { keysApi } from "@/api/keys";
import { settingsApi } from "@/api/settings";
import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models";
import { Add, Close, HelpCircleOutline, Remove } from "@vicons/ionicons5";
import {
NButton,
NCard,
NForm,
NFormItem,
NIcon,
NInput,
NInputNumber,
NModal,
NSelect,
NTooltip,
useMessage,
type FormRules,
} from "naive-ui";
import { computed, reactive, ref, watch } from "vue";
interface Props {
show: boolean;
group?: Group | null;
}
interface Emits {
(e: "update:show", value: boolean): void;
(e: "success", value: Group): void;
(e: "switchToGroup", groupId: number): void;
}
// 配置项类型
interface ConfigItem {
key: string;
value: number;
}
const props = withDefaults(defineProps<Props>(), {
group: null,
});
const emit = defineEmits<Emits>();
const message = useMessage();
const loading = ref(false);
const formRef = ref();
// 表单数据接口
interface GroupFormData {
name: string;
display_name: string;
description: string;
upstreams: UpstreamInfo[];
channel_type: "anthropic" | "gemini" | "openai";
sort: number;
test_model: string;
validation_endpoint: string;
param_overrides: string;
config: Record<string, number>;
configItems: ConfigItem[];
}
// 表单数据
const formData = reactive<GroupFormData>({
name: "",
display_name: "",
description: "",
upstreams: [
{
url: "",
weight: 1,
},
] as UpstreamInfo[],
channel_type: "openai",
sort: 1,
test_model: "",
validation_endpoint: "",
param_overrides: "",
config: {},
configItems: [] as ConfigItem[],
});
const channelTypeOptions = ref<{ label: string; value: string }[]>([]);
const configOptions = ref<GroupConfigOption[]>([]);
const channelTypesFetched = ref(false);
const configOptionsFetched = ref(false);
// 跟踪用户是否已手动修改过字段(仅在新增模式下使用)
const userModifiedFields = ref({
test_model: false,
upstream: false,
});
// 根据渠道类型动态生成占位符提示
const testModelPlaceholder = computed(() => {
switch (formData.channel_type) {
case "openai":
return "gpt-4.1-nano";
case "gemini":
return "gemini-2.0-flash-lite";
case "anthropic":
return "claude-3-haiku-20240307";
default:
return "请输入模型名称";
}
});
const upstreamPlaceholder = computed(() => {
switch (formData.channel_type) {
case "openai":
return "https://api.openai.com";
case "gemini":
return "https://generativelanguage.googleapis.com";
case "anthropic":
return "https://api.anthropic.com";
default:
return "请输入上游地址";
}
});
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: [
{
required: true,
message: "请输入分组名称",
trigger: ["blur", "input"],
},
{
pattern: /^[a-z0-9_-]{3,30}$/,
message: "只能包含小写字母、数字、中划线或下划线长度3-30位",
trigger: ["blur", "input"],
},
],
channel_type: [
{
required: true,
message: "请选择渠道类型",
trigger: ["blur", "change"],
},
],
test_model: [
{
required: true,
message: "请输入测试模型",
trigger: ["blur", "input"],
},
],
upstreams: [
{
type: "array",
min: 1,
message: "至少需要一个上游地址",
trigger: ["blur", "change"],
},
],
};
// 监听弹窗显示状态
watch(
() => props.show,
show => {
if (show) {
if (!channelTypesFetched.value) {
fetchChannelTypes();
}
if (!configOptionsFetched.value) {
fetchGroupConfigOptions();
}
resetForm();
if (props.group) {
loadGroupData();
}
}
}
);
// 监听渠道类型变化,在新增模式下智能更新默认值
watch(
() => formData.channel_type,
(_newChannelType, oldChannelType) => {
if (!props.group && oldChannelType) {
// 仅在新增模式且不是初始设置时处理
// 检查测试模型是否应该更新(为空或是旧渠道类型的默认值)
if (
!userModifiedFields.value.test_model ||
formData.test_model === getOldDefaultTestModel(oldChannelType)
) {
formData.test_model = testModelPlaceholder.value;
userModifiedFields.value.test_model = false;
}
// 检查第一个上游地址是否应该更新
if (
formData.upstreams.length > 0 &&
(!userModifiedFields.value.upstream ||
formData.upstreams[0].url === getOldDefaultUpstream(oldChannelType))
) {
formData.upstreams[0].url = upstreamPlaceholder.value;
userModifiedFields.value.upstream = false;
}
}
}
);
// 获取旧渠道类型的默认值(用于比较)
function getOldDefaultTestModel(channelType: string): string {
switch (channelType) {
case "openai":
return "gpt-4.1-nano";
case "gemini":
return "gemini-2.0-flash-lite";
case "anthropic":
return "claude-3-haiku-20240307";
default:
return "";
}
}
function getOldDefaultUpstream(channelType: string): string {
switch (channelType) {
case "openai":
return "https://api.openai.com";
case "gemini":
return "https://generativelanguage.googleapis.com";
case "anthropic":
return "https://api.anthropic.com";
default:
return "";
}
}
// 重置表单
function resetForm() {
const isCreateMode = !props.group;
const defaultChannelType = "openai";
// 先设置渠道类型,这样 computed 属性能正确计算默认值
formData.channel_type = defaultChannelType;
Object.assign(formData, {
name: "",
display_name: "",
description: "",
upstreams: [
{
url: isCreateMode ? upstreamPlaceholder.value : "",
weight: 1,
},
],
channel_type: defaultChannelType,
sort: 1,
test_model: isCreateMode ? testModelPlaceholder.value : "",
validation_endpoint: "",
param_overrides: "",
config: {},
configItems: [],
});
// 重置用户修改状态追踪
if (isCreateMode) {
userModifiedFields.value = {
test_model: false,
upstream: false,
};
}
}
// 加载分组数据(编辑模式)
function loadGroupData() {
if (!props.group) {
return;
}
const configItems = Object.entries(props.group.config || {}).map(([key, value]) => ({
key,
value: Number(value) || 0,
}));
Object.assign(formData, {
name: props.group.name || "",
display_name: props.group.display_name || "",
description: props.group.description || "",
upstreams: props.group.upstreams?.length
? [...props.group.upstreams]
: [{ url: "", weight: 1 }],
channel_type: props.group.channel_type || "openai",
sort: props.group.sort || 1,
test_model: props.group.test_model || "",
validation_endpoint: props.group.validation_endpoint || "",
param_overrides: JSON.stringify(props.group.param_overrides || {}, null, 2),
config: {},
configItems,
});
}
async function fetchChannelTypes() {
const options = (await settingsApi.getChannelTypes()) || [];
channelTypeOptions.value =
options?.map((type: string) => ({
label: type,
value: type,
})) || [];
channelTypesFetched.value = true;
}
// 添加上游地址
function addUpstream() {
formData.upstreams.push({
url: "",
weight: 1,
});
}
// 删除上游地址
function removeUpstream(index: number) {
if (formData.upstreams.length > 1) {
formData.upstreams.splice(index, 1);
} else {
message.warning("至少需要保留一个上游地址");
}
}
async function fetchGroupConfigOptions() {
const options = await keysApi.getGroupConfigOptions();
configOptions.value = options || [];
configOptionsFetched.value = true;
}
// 添加配置项
function addConfigItem() {
formData.configItems.push({
key: "",
value: 0,
});
}
// 删除配置项
function removeConfigItem(index: number) {
formData.configItems.splice(index, 1);
}
// 当配置项的key改变时设置默认值
function handleConfigKeyChange(index: number, key: string) {
const option = configOptions.value.find(opt => opt.key === key);
if (option) {
formData.configItems[index].value = option.default_value || 0;
}
}
// 关闭弹窗
function handleClose() {
emit("update:show", false);
}
// 提交表单
async function handleSubmit() {
if (loading.value) {
return;
}
try {
await formRef.value?.validate();
loading.value = true;
// 验证 JSON 格式
let paramOverrides = {};
if (formData.param_overrides) {
try {
paramOverrides = JSON.parse(formData.param_overrides);
} catch {
message.error("参数覆盖必须是有效的 JSON 格式");
return;
}
}
// 将configItems转换为config对象
const config: Record<string, number> = {};
formData.configItems.forEach((item: ConfigItem) => {
if (item.key && item.key.trim()) {
config[item.key] = item.value;
}
});
// 构建提交数据
const submitData = {
name: formData.name,
display_name: formData.display_name,
description: formData.description,
upstreams: formData.upstreams.filter((upstream: UpstreamInfo) => upstream.url.trim()),
channel_type: formData.channel_type,
sort: formData.sort,
test_model: formData.test_model,
validation_endpoint: formData.validation_endpoint,
param_overrides: paramOverrides,
config,
};
let res: Group;
if (props.group?.id) {
// 编辑模式
res = await keysApi.updateGroup(props.group.id, submitData);
} else {
// 新建模式
res = await keysApi.createGroup(submitData);
}
emit("success", res);
// 如果是新建模式,发出切换到新分组的事件
if (!props.group?.id && res.id) {
emit("switchToGroup", res.id);
}
handleClose();
} finally {
loading.value = false;
}
}
</script>
<template>
<n-modal :show="show" @update:show="handleClose" class="group-form-modal">
<n-card
style="width: 800px"
:title="group ? '编辑分组' : '创建分组'"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button quaternary circle @click="handleClose">
<template #icon>
<n-icon :component="Close" />
</template>
</n-button>
</template>
<n-form
ref="formRef"
:model="formData"
:rules="rules"
label-placement="left"
label-width="120px"
require-mark-placement="right-hanging"
>
<!-- 基础信息 -->
<div class="form-section">
<h4 class="section-title">基础信息</h4>
<!-- 分组名称和显示名称在同一行 -->
<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" 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>
<!-- 渠道类型和排序在同一行 -->
<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="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>
<!-- 测试模型和测试路径在同一行 -->
<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"
@input="() => !props.group && (userModifiedFields.test_model = true)"
/>
</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="1"
:autosize="{ minRows: 1, maxRows: 5 }"
style="resize: none"
/>
</n-form-item>
</div>
<!-- 上游地址 -->
<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"
:label="`上游 ${index + 1}`"
:path="`upstreams[${index}].url`"
:rule="{
required: true,
message: '',
trigger: ['blur', 'input'],
}"
>
<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"
@input="() => !props.group && index === 0 && (userModifiedFields.upstream = true)"
/>
</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
size="small"
>
<template #icon>
<n-icon :component="Remove" />
</template>
</n-button>
</div>
</div>
</n-form-item>
<n-form-item>
<n-button @click="addUpstream" dashed style="width: 100%">
<template #icon>
<n-icon :component="Add" />
</template>
添加上游地址
</n-button>
</n-form-item>
</div>
<!-- 高级配置 -->
<div class="form-section" style="margin-top: 10px">
<n-collapse>
<n-collapse-item name="advanced">
<template #header>高级配置</template>
<div class="config-section">
<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="config-item-row"
:label="`配置 ${index + 1}`"
:path="`configItems[${index}].key`"
:rule="{
required: true,
message: '',
trigger: ['blur', 'change'],
}"
>
<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>
<div style="margin-top: 12px; padding-left: 120px">
<n-button
@click="addConfigItem"
dashed
style="width: 100%"
:disabled="formData.configItems.length >= configOptions.length"
>
<template #icon>
<n-icon :component="Add" />
</template>
添加配置参数
</n-button>
</div>
</div>
<div class="config-section">
<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>
</div>
</n-form>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 12px">
<n-button @click="handleClose">取消</n-button>
<n-button type="primary" @click="handleSubmit" :loading="loading">
{{ group ? "更新" : "创建" }}
</n-button>
</div>
</template>
</n-card>
</n-modal>
</template>
<style scoped>
.group-form-modal {
--n-color: rgba(255, 255, 255, 0.95);
}
.form-section {
margin-top: 20px;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid rgba(102, 126, 234, 0.1);
}
:deep(.n-form-item-label) {
font-weight: 500;
}
:deep(.n-form-item-blank) {
flex-grow: 1;
}
:deep(.n-input) {
--n-border-radius: 6px;
}
:deep(.n-select) {
--n-border-radius: 6px;
}
:deep(.n-input-number) {
--n-border-radius: 6px;
}
:deep(.n-card-header) {
border-bottom: 1px solid rgba(239, 239, 245, 0.8);
padding: 10px 20px;
}
:deep(.n-card__content) {
max-height: calc(100vh - 68px - 61px - 50px);
overflow-y: auto;
}
:deep(.n-card__footer) {
border-top: 1px solid rgba(239, 239, 245, 0.8);
padding: 10px 15px;
}
:deep(.n-form-item-feedback-wrapper) {
min-height: 10px;
}
.config-section {
margin-top: 16px;
}
.config-title {
font-size: 0.9rem;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
}
.form-label {
margin-left: 25px;
margin-right: 10px;
height: 34px;
line-height: 34px;
font-weight: 500;
}
/* 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>