1084 lines
30 KiB
Vue
1084 lines
30 KiB
Vue
<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位。例如:gemini、openai-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提供商类型,决定了请求格式和认证方式。支持OpenAI、Gemini、Anthropic等主流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>
|
||
决定分组在列表中的显示顺序,数字越小越靠前。建议使用10、20、30这样的间隔数字,便于后续调整
|
||
</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请求参数。例如: {"temperature": 0.7,
|
||
"max_tokens": 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>
|