Files
gpt-load/web/src/components/keys/GroupFormModal.vue
2025-07-05 22:50:51 +08:00

563 lines
15 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, Upstream } from "@/types/models";
import {
NButton,
NCard,
NCollapse,
NCollapseItem,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NSelect,
NSpace,
useMessage,
} from "naive-ui";
import { reactive, ref, watch } from "vue";
interface Props {
show: boolean;
group?: Group | null;
}
interface Emits {
(e: "update:show", value: boolean): void;
(e: "success"): 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();
// 表单数据
const formData = reactive<any>({
name: "",
display_name: "",
description: "",
upstreams: [
{
url: "",
weight: 1,
},
] as Upstream[],
channel_type: "openai",
sort: 1,
test_model: "",
param_overrides: "",
config: {},
configItems: [] as ConfigItem[],
});
const channelTypeOptions = ref<{ label: string; value: string }[]>([]);
const configOptions = ref<GroupConfigOption[]>([]);
// 表单验证规则
const rules = {
name: {
required: true,
message: "请输入分组名称",
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) {
resetForm();
if (props.group) {
loadGroupData();
}
}
}
);
// 重置表单
function resetForm() {
Object.assign(formData, {
name: "",
display_name: "",
description: "",
upstreams: [{ url: "", weight: 1 }],
channel_type: "openai",
sort: 1,
test_model: "",
param_overrides: "",
config: {},
configItems: [],
});
}
// 加载分组数据(编辑模式)
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.config?.test_model || "",
param_overrides: JSON.stringify(props.group.config?.param_overrides || {}, null, 2),
config: {},
configItems,
});
}
fetchChannelTypes();
async function fetchChannelTypes() {
const options = (await settingsApi.getChannelTypes()) || [];
channelTypeOptions.value =
options?.map((type: string) => ({
label: type,
value: type,
})) || [];
}
// 添加上游地址
function addUpstream() {
formData.upstreams.push({
url: "",
weight: 1,
});
}
// 删除上游地址
function removeUpstream(index: number) {
if (formData.upstreams.length > 1) {
formData.upstreams.splice(index, 1);
}
}
fetchGroupConfigOptions();
async function fetchGroupConfigOptions() {
const options = await keysApi.getGroupConfigOptions();
configOptions.value = options || [];
}
// 添加配置项
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() {
try {
await formRef.value?.validate();
loading.value = true;
// 验证 JSON 格式
let paramOverrides = {};
try {
paramOverrides = JSON.parse(formData.param_overrides);
} catch {
message.error("参数覆盖必须是有效的 JSON 格式");
return;
}
// 将configItems转换为config对象
const config: Record<string, number> = {};
formData.configItems.forEach(item => {
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 => upstream.url.trim()),
channel_type: formData.channel_type,
sort: formData.sort,
test_model: formData.test_model,
param_overrides: paramOverrides,
config,
};
if (props.group?.id) {
// 编辑模式
await keysApi.updateGroup(props.group.id, submitData);
message.success("分组更新成功");
} else {
// 新建模式
await keysApi.createGroup(submitData);
message.success("分组创建成功");
}
emit("success");
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>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
/>
</svg>
</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>
<n-form-item label="分组名称" path="name">
<n-input
v-model:value="formData.name"
placeholder="请输入分组名称gemini"
:disabled="!!group"
/>
</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="description">
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="可选,分组描述信息"
:rows="3"
/>
</n-form-item>
<n-form-item label="渠道类型" path="channel_type">
<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="如gpt-3.5-turbo" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number
v-model:value="formData.sort"
:min="0"
placeholder="排序值,数字越小越靠前"
/>
</n-form-item>
</div>
<!-- 上游地址 -->
<div class="form-section">
<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'],
}"
>
<n-space style="width: 100%">
<n-input
v-model:value="upstream.url"
placeholder="https://api.openai.com"
style="flex: 1"
/>
<n-input-number
v-model:value="upstream.weight"
:min="1"
placeholder="权重"
style="width: 80px"
/>
<n-button
v-if="formData.upstreams.length > 1"
@click="removeUpstream(index)"
type="error"
quaternary
circle
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z" />
</svg>
</template>
</n-button>
</n-space>
</n-form-item>
<n-form-item>
<n-button @click="addUpstream" dashed style="width: 100%">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</template>
添加上游地址
</n-button>
</n-form-item>
</div>
<!-- 高级配置 -->
<div class="form-section">
<n-collapse>
<n-collapse-item title="高级配置" name="advanced">
<div class="config-section">
<h5 class="config-title">分组配置</h5>
<div class="config-items">
<n-form-item
v-for="(configItem, index) in formData.configItems"
:key="index"
class="flex config-item"
:label="`配置 ${index + 1}`"
:path="`configItems[${index}].key`"
:rule="{
required: true,
message: '请选择配置参数',
trigger: ['blur', 'change'],
}"
>
<n-space style="width: 100%" align="center">
<n-select
v-model:value="configItem.key"
:options="
configOptions.map(opt => ({
label: opt.name,
value: opt.key,
disabled:
formData.configItems.map(item => 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: 150px"
:precision="0"
/>
<n-button
@click="removeConfigItem(index)"
type="error"
quaternary
circle
size="small"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z" />
</svg>
</template>
</n-button>
</n-space>
</n-form-item>
</div>
<n-button
@click="addConfigItem"
dashed
style="width: 100%; margin-top: 12px"
:disabled="formData.configItems.length >= configOptions.length"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</template>
添加配置参数
</n-button>
</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="8"
/>
</n-form-item>
</div>
</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: 24px;
}
.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-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);
}
:deep(.n-card-footer) {
border-top: 1px solid rgba(239, 239, 245, 0.8);
}
.config-section {
margin-top: 16px;
}
.config-title {
font-size: 0.9rem;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
}
/* .empty-config {
color: #9ca3af;
font-style: italic;
text-align: center;
padding: 20px 0;
} */
.config-item {
margin-bottom: 12px;
}
:deep(.n-base-selection-label) {
height: 40px;
}
</style>