group add

This commit is contained in:
hptangxi
2025-07-05 22:50:51 +08:00
parent 8f2a0c1afc
commit 22b4e495bc
10 changed files with 679 additions and 725 deletions

View File

@@ -0,0 +1,562 @@
<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>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { keysApi } from "@/api/keys";
import type { Group, GroupStats } from "@/types/models";
import { getGroupDisplayName } from "@/utils/display";
import {
NButton,
NCard,
@@ -14,7 +15,6 @@ import {
NTag,
useMessage,
} from "naive-ui";
import { getGroupDisplayName } from "@/utils/display";
import { onMounted, ref, watch } from "vue";
interface Props {
@@ -47,8 +47,6 @@ async function loadStats() {
try {
loading.value = true;
stats.value = await keysApi.getGroupStats(props.group.id);
} catch (_error) {
// 错误已记录
} finally {
loading.value = false;
}

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { keysApi } from "@/api/keys";
import type { Group } from "@/types/models";
import { getGroupDisplayName } from "@/utils/display";
import { NButton, NCard, NEmpty, NInput, NSpin, NTag, useMessage } from "naive-ui";
import { Add, Search } from "@vicons/ionicons5";
import { NButton, NCard, NEmpty, NInput, NSpin, NTag } from "naive-ui";
import { computed, ref } from "vue";
import GroupFormModal from "./GroupFormModal.vue";
interface Props {
groups: Group[];
@@ -23,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>();
const searchText = ref("");
const message = useMessage();
const showGroupModal = ref(false);
// 过滤后的分组列表
const filteredGroups = computed(() => {
@@ -54,29 +55,13 @@ function getChannelTagType(channelType: string) {
}
}
// 简单的创建分组功能(演示用)
async function createDemoGroup() {
try {
const newGroup = await keysApi.createGroup({
name: `demo-group-${Date.now()}`,
display_name: `演示分组 ${props.groups.length + 1}`,
description: "这是一个演示分组",
sort: props.groups.length + 1,
channel_type: "openai",
upstreams: [{ url: "https://api.openai.com", weight: 1 }],
config: {
test_model: "gpt-3.5-turbo",
param_overrides: {},
request_timeout: 30000,
},
});
function openCreateGroupModal() {
showGroupModal.value = true;
}
message.success(`创建分组成功: ${newGroup.display_name}`);
emit("refresh");
} catch (_error) {
// 错误已记录
message.error("创建分组失败");
}
function handleGroupCreated() {
showGroupModal.value = false;
emit("refresh");
}
</script>
@@ -87,11 +72,7 @@ async function createDemoGroup() {
<div class="search-section">
<n-input v-model:value="searchText" placeholder="搜索分组名称..." size="small" clearable>
<template #prefix>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<n-icon :component="Search" />
</template>
</n-input>
</div>
@@ -131,16 +112,15 @@ async function createDemoGroup() {
<!-- 添加分组按钮 -->
<div class="add-section">
<n-button type="primary" size="small" block @click="createDemoGroup">
<n-button type="primary" size="small" block @click="openCreateGroupModal">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
<n-icon :component="Add" />
</template>
创建分组
</n-button>
</div>
</n-card>
<group-form-modal v-model:show="showGroupModal" @success="handleGroupCreated" />
</div>
</template>

View File

@@ -293,7 +293,7 @@ async function validateAllKeys() {
}
try {
const result = await keysApi.validateKeys(props.selectedGroup.id);
const result = await keysApi.validateGroupKeys(props.selectedGroup.id);
window.$message.success(`验证完成: 有效${result.valid_count}个,无效${result.invalid_count}`);
} catch (_error) {
// 错误已记录