@@ -62,6 +62,16 @@ async function pollOnce() {
|
|||||||
localStorage.setItem("last_closed_task", task.finished_at || "");
|
localStorage.setItem("last_closed_task", task.finished_at || "");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 触发分组数据刷新
|
||||||
|
if (task.group_name && task.finished_at) {
|
||||||
|
appState.lastCompletedTask = {
|
||||||
|
groupName: task.group_name,
|
||||||
|
taskType: task.task_type,
|
||||||
|
finishedAt: task.finished_at,
|
||||||
|
};
|
||||||
|
appState.groupDataRefreshTrigger++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
@@ -2,18 +2,18 @@
|
|||||||
import { keysApi } from "@/api/keys";
|
import { keysApi } from "@/api/keys";
|
||||||
import { settingsApi } from "@/api/settings";
|
import { settingsApi } from "@/api/settings";
|
||||||
import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models";
|
import type { Group, GroupConfigOption, UpstreamInfo } from "@/types/models";
|
||||||
import { Add, Close, Remove } from "@vicons/ionicons5";
|
import { Add, Close, HelpCircleOutline, Remove } from "@vicons/ionicons5";
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NCard,
|
NCard,
|
||||||
NCollapse,
|
|
||||||
NCollapseItem,
|
|
||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
|
NIcon,
|
||||||
NInput,
|
NInput,
|
||||||
NInputNumber,
|
NInputNumber,
|
||||||
NModal,
|
NModal,
|
||||||
NSelect,
|
NSelect,
|
||||||
|
NTooltip,
|
||||||
useMessage,
|
useMessage,
|
||||||
type FormRules,
|
type FormRules,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
@@ -27,6 +27,7 @@ interface Props {
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: "update:show", value: boolean): void;
|
(e: "update:show", value: boolean): void;
|
||||||
(e: "success", value: Group): void;
|
(e: "success", value: Group): void;
|
||||||
|
(e: "switchToGroup", groupId: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置项类型
|
// 配置项类型
|
||||||
@@ -51,7 +52,7 @@ interface GroupFormData {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description: string;
|
description: string;
|
||||||
upstreams: UpstreamInfo[];
|
upstreams: UpstreamInfo[];
|
||||||
channel_type: "openai" | "gemini" | "anthropic";
|
channel_type: "anthropic" | "gemini" | "openai";
|
||||||
sort: number;
|
sort: number;
|
||||||
test_model: string;
|
test_model: string;
|
||||||
validation_endpoint: string;
|
validation_endpoint: string;
|
||||||
@@ -85,15 +86,21 @@ const configOptions = ref<GroupConfigOption[]>([]);
|
|||||||
const channelTypesFetched = ref(false);
|
const channelTypesFetched = ref(false);
|
||||||
const configOptionsFetched = ref(false);
|
const configOptionsFetched = ref(false);
|
||||||
|
|
||||||
|
// 跟踪用户是否已手动修改过字段(仅在新增模式下使用)
|
||||||
|
const userModifiedFields = ref({
|
||||||
|
test_model: false,
|
||||||
|
upstream: false,
|
||||||
|
});
|
||||||
|
|
||||||
// 根据渠道类型动态生成占位符提示
|
// 根据渠道类型动态生成占位符提示
|
||||||
const testModelPlaceholder = computed(() => {
|
const testModelPlaceholder = computed(() => {
|
||||||
switch (formData.channel_type) {
|
switch (formData.channel_type) {
|
||||||
case "openai":
|
case "openai":
|
||||||
return "如:gpt-4.1-nano";
|
return "gpt-4.1-nano";
|
||||||
case "gemini":
|
case "gemini":
|
||||||
return "如:gemini-2.0-flash-lite";
|
return "gemini-2.0-flash-lite";
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return "如:claude-3-haiku-20240307";
|
return "claude-3-haiku-20240307";
|
||||||
default:
|
default:
|
||||||
return "请输入模型名称";
|
return "请输入模型名称";
|
||||||
}
|
}
|
||||||
@@ -112,6 +119,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 = {
|
const rules: FormRules = {
|
||||||
name: [
|
name: [
|
||||||
@@ -169,21 +189,95 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 监听渠道类型变化,在新增模式下智能更新默认值
|
||||||
|
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() {
|
function resetForm() {
|
||||||
|
const isCreateMode = !props.group;
|
||||||
|
const defaultChannelType = "openai";
|
||||||
|
|
||||||
|
// 先设置渠道类型,这样 computed 属性能正确计算默认值
|
||||||
|
formData.channel_type = defaultChannelType;
|
||||||
|
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
name: "",
|
name: "",
|
||||||
display_name: "",
|
display_name: "",
|
||||||
description: "",
|
description: "",
|
||||||
upstreams: [{ url: "", weight: 1 }],
|
upstreams: [
|
||||||
channel_type: "openai",
|
{
|
||||||
|
url: isCreateMode ? upstreamPlaceholder.value : "",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
channel_type: defaultChannelType,
|
||||||
sort: 1,
|
sort: 1,
|
||||||
test_model: "",
|
test_model: isCreateMode ? testModelPlaceholder.value : "",
|
||||||
validation_endpoint: "",
|
validation_endpoint: "",
|
||||||
param_overrides: "",
|
param_overrides: "",
|
||||||
config: {},
|
config: {},
|
||||||
configItems: [],
|
configItems: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 重置用户修改状态追踪
|
||||||
|
if (isCreateMode) {
|
||||||
|
userModifiedFields.value = {
|
||||||
|
test_model: false,
|
||||||
|
upstream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载分组数据(编辑模式)
|
// 加载分组数据(编辑模式)
|
||||||
@@ -326,6 +420,10 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emit("success", res);
|
emit("success", res);
|
||||||
|
// 如果是新建模式,发出切换到新分组的事件
|
||||||
|
if (!props.group?.id && res.id) {
|
||||||
|
emit("switchToGroup", res.id);
|
||||||
|
}
|
||||||
handleClose();
|
handleClose();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -363,52 +461,156 @@ async function handleSubmit() {
|
|||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h4 class="section-title">基础信息</h4>
|
<h4 class="section-title">基础信息</h4>
|
||||||
|
|
||||||
<n-form-item label="分组名称" path="name">
|
<!-- 分组名称和显示名称在同一行 -->
|
||||||
<n-input v-model:value="formData.name" placeholder="作为路由的一部分,如:gemini" />
|
<div class="form-row">
|
||||||
</n-form-item>
|
<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">
|
<n-form-item label="显示名称" path="display_name" class="form-item-half">
|
||||||
<n-input v-model:value="formData.display_name" placeholder="可选,用于显示的友好名称" />
|
<template #label>
|
||||||
</n-form-item>
|
<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
|
<div class="form-row">
|
||||||
v-model:value="formData.channel_type"
|
<n-form-item label="渠道类型" path="channel_type" class="form-item-half">
|
||||||
:options="channelTypeOptions"
|
<template #label>
|
||||||
placeholder="请选择渠道类型"
|
<div class="form-label-with-tooltip">
|
||||||
/>
|
渠道类型
|
||||||
</n-form-item>
|
<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="test_model">
|
<n-form-item label="排序" path="sort" class="form-item-half">
|
||||||
<n-input v-model:value="formData.test_model" :placeholder="testModelPlaceholder" />
|
<template #label>
|
||||||
</n-form-item>
|
<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>
|
||||||
|
|
||||||
<n-form-item
|
<!-- 测试模型和测试路径在同一行 -->
|
||||||
label="测试路径"
|
<div class="form-row">
|
||||||
path="validation_endpoint"
|
<n-form-item label="测试模型" path="test_model" class="form-item-half">
|
||||||
v-if="formData.channel_type !== 'gemini'"
|
<template #label>
|
||||||
>
|
<div class="form-label-with-tooltip">
|
||||||
<n-input
|
测试模型
|
||||||
v-model:value="formData.validation_endpoint"
|
<n-tooltip trigger="hover" placement="top">
|
||||||
placeholder="可选,自定义用于验证key的API路径"
|
<template #trigger>
|
||||||
/>
|
<n-icon :component="HelpCircleOutline" class="help-icon" />
|
||||||
</n-form-item>
|
</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="sort">
|
<n-form-item
|
||||||
<n-input-number
|
label="测试路径"
|
||||||
v-model:value="formData.sort"
|
path="validation_endpoint"
|
||||||
:min="0"
|
class="form-item-half"
|
||||||
placeholder="排序值,数字越小越靠前"
|
v-if="formData.channel_type !== 'gemini'"
|
||||||
/>
|
>
|
||||||
</n-form-item>
|
<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">
|
<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
|
<n-input
|
||||||
v-model:value="formData.description"
|
v-model:value="formData.description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
placeholder="可选,分组描述信息"
|
placeholder=""
|
||||||
:rows="2"
|
:rows="1"
|
||||||
:autosize="{ minRows: 2, maxRows: 2 }"
|
:autosize="{ minRows: 1, maxRows: 5 }"
|
||||||
style="resize: none"
|
style="resize: none"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -417,7 +619,6 @@ async function handleSubmit() {
|
|||||||
<!-- 上游地址 -->
|
<!-- 上游地址 -->
|
||||||
<div class="form-section" style="margin-top: 10px">
|
<div class="form-section" style="margin-top: 10px">
|
||||||
<h4 class="section-title">上游地址</h4>
|
<h4 class="section-title">上游地址</h4>
|
||||||
|
|
||||||
<n-form-item
|
<n-form-item
|
||||||
v-for="(upstream, index) in formData.upstreams"
|
v-for="(upstream, index) in formData.upstreams"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -429,27 +630,42 @@ async function handleSubmit() {
|
|||||||
trigger: ['blur', 'input'],
|
trigger: ['blur', 'input'],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2" style="width: 100%">
|
<template #label>
|
||||||
<n-input
|
<div class="form-label-with-tooltip">
|
||||||
v-model:value="upstream.url"
|
上游 {{ index + 1 }}
|
||||||
:placeholder="upstreamPlaceholder"
|
<n-tooltip trigger="hover" placement="top">
|
||||||
style="flex: 1"
|
<template #trigger>
|
||||||
/>
|
<n-icon :component="HelpCircleOutline" class="help-icon" />
|
||||||
<span class="form-label">权重</span>
|
</template>
|
||||||
<n-input-number
|
API服务器的完整URL地址。多个上游可以实现负载均衡和故障转移,提高服务可用性
|
||||||
v-model:value="upstream.weight"
|
</n-tooltip>
|
||||||
:min="1"
|
</div>
|
||||||
placeholder="权重"
|
</template>
|
||||||
style="width: 100px"
|
<div class="upstream-row">
|
||||||
/>
|
<div class="upstream-url">
|
||||||
<div style="width: 40px">
|
<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
|
<n-button
|
||||||
v-if="formData.upstreams.length > 1"
|
v-if="formData.upstreams.length > 1"
|
||||||
@click="removeUpstream(index)"
|
@click="removeUpstream(index)"
|
||||||
type="error"
|
type="error"
|
||||||
quaternary
|
quaternary
|
||||||
circle
|
circle
|
||||||
style="margin-left: 10px"
|
size="small"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="Remove" />
|
<n-icon :component="Remove" />
|
||||||
@@ -472,15 +688,24 @@ async function handleSubmit() {
|
|||||||
<!-- 高级配置 -->
|
<!-- 高级配置 -->
|
||||||
<div class="form-section" style="margin-top: 10px">
|
<div class="form-section" style="margin-top: 10px">
|
||||||
<n-collapse>
|
<n-collapse>
|
||||||
<n-collapse-item title="高级配置" name="advanced">
|
<n-collapse-item name="advanced">
|
||||||
|
<template #header>高级配置</template>
|
||||||
<div class="config-section">
|
<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">
|
<div class="config-items">
|
||||||
<n-form-item
|
<n-form-item
|
||||||
v-for="(configItem, index) in formData.configItems"
|
v-for="(configItem, index) in formData.configItems"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex config-item"
|
class="config-item-row"
|
||||||
:label="`配置 ${index + 1}`"
|
:label="`配置 ${index + 1}`"
|
||||||
:path="`configItems[${index}].key`"
|
:path="`configItems[${index}].key`"
|
||||||
:rule="{
|
:rule="{
|
||||||
@@ -489,42 +714,56 @@ async function handleSubmit() {
|
|||||||
trigger: ['blur', 'change'],
|
trigger: ['blur', 'change'],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center" style="width: 100%">
|
<template #label>
|
||||||
<n-select
|
<div class="form-label-with-tooltip">
|
||||||
v-model:value="configItem.key"
|
配置 {{ index + 1 }}
|
||||||
:options="
|
<n-tooltip trigger="hover" placement="top">
|
||||||
configOptions.map(opt => ({
|
<template #trigger>
|
||||||
label: opt.name,
|
<n-icon :component="HelpCircleOutline" class="help-icon" />
|
||||||
value: opt.key,
|
</template>
|
||||||
disabled:
|
选择要配置的参数类型,然后设置对应的数值。不同参数有不同的作用和取值范围
|
||||||
formData.configItems
|
</n-tooltip>
|
||||||
.map((item: ConfigItem) => item.key)
|
</div>
|
||||||
?.includes(opt.key) && opt.key !== configItem.key,
|
</template>
|
||||||
}))
|
<div class="config-item-content">
|
||||||
"
|
<div class="config-select">
|
||||||
placeholder="请选择配置参数"
|
<n-select
|
||||||
style="min-width: 200px"
|
v-model:value="configItem.key"
|
||||||
@update:value="value => handleConfigKeyChange(index, value)"
|
:options="
|
||||||
clearable
|
configOptions.map(opt => ({
|
||||||
/>
|
label: opt.name,
|
||||||
<n-input-number
|
value: opt.key,
|
||||||
v-model:value="configItem.value"
|
disabled:
|
||||||
placeholder="参数值"
|
formData.configItems
|
||||||
style="width: 180px; margin-left: 15px"
|
.map((item: ConfigItem) => item.key)
|
||||||
:precision="0"
|
?.includes(opt.key) && opt.key !== configItem.key,
|
||||||
/>
|
}))
|
||||||
<n-button
|
"
|
||||||
@click="removeConfigItem(index)"
|
placeholder="请选择配置参数"
|
||||||
type="error"
|
@update:value="value => handleConfigKeyChange(index, value)"
|
||||||
quaternary
|
clearable
|
||||||
circle
|
/>
|
||||||
size="small"
|
</div>
|
||||||
style="margin-left: 10px"
|
<div class="config-value">
|
||||||
>
|
<n-input-number
|
||||||
<template #icon>
|
v-model:value="configItem.value"
|
||||||
<n-icon :component="Remove" />
|
placeholder="参数值"
|
||||||
</template>
|
:precision="0"
|
||||||
</n-button>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</div>
|
</div>
|
||||||
@@ -544,17 +783,26 @@ async function handleSubmit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<h5 class="config-title">参数覆盖</h5>
|
<n-form-item path="param_overrides">
|
||||||
<div class="config-items">
|
<template #label>
|
||||||
<n-form-item path="param_overrides">
|
<div class="form-label-with-tooltip">
|
||||||
<n-input
|
参数覆盖
|
||||||
v-model:value="formData.param_overrides"
|
<n-tooltip trigger="hover" placement="top">
|
||||||
type="textarea"
|
<template #trigger>
|
||||||
placeholder="JSON 格式的参数覆盖配置"
|
<n-icon :component="HelpCircleOutline" class="help-icon config-help" />
|
||||||
:rows="4"
|
</template>
|
||||||
/>
|
使用JSON格式定义要覆盖的API请求参数。例如: {"temperature": 0.7,
|
||||||
</n-form-item>
|
"max_tokens": 2000}。这些参数会在发送请求时合并到原始参数中
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</n-collapse-item>
|
</n-collapse-item>
|
||||||
</n-collapse>
|
</n-collapse>
|
||||||
@@ -649,10 +897,187 @@ async function handleSubmit() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item {
|
/* Tooltip相关样式 */
|
||||||
margin-bottom: 12px;
|
.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) {
|
:deep(.n-base-selection-label) {
|
||||||
height: 40px;
|
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>
|
</style>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { keysApi } from "@/api/keys";
|
import { keysApi } from "@/api/keys";
|
||||||
import type { Group, GroupStatsResponse } from "@/types/models";
|
import type { Group, GroupConfigOption, GroupStatsResponse } from "@/types/models";
|
||||||
|
import { appState } from "@/utils/app-state";
|
||||||
import { copy } from "@/utils/clipboard";
|
import { copy } from "@/utils/clipboard";
|
||||||
import { getGroupDisplayName } from "@/utils/display";
|
import { getGroupDisplayName } from "@/utils/display";
|
||||||
import { Pencil, Trash } from "@vicons/ionicons5";
|
import { Pencil, Trash } from "@vicons/ionicons5";
|
||||||
@@ -13,8 +14,10 @@ import {
|
|||||||
NFormItem,
|
NFormItem,
|
||||||
NGrid,
|
NGrid,
|
||||||
NGridItem,
|
NGridItem,
|
||||||
|
NIcon,
|
||||||
NSpin,
|
NSpin,
|
||||||
NTag,
|
NTag,
|
||||||
|
NTooltip,
|
||||||
useDialog,
|
useDialog,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
@@ -39,9 +42,11 @@ const dialog = useDialog();
|
|||||||
const showEditModal = ref(false);
|
const showEditModal = ref(false);
|
||||||
const delLoading = ref(false);
|
const delLoading = ref(false);
|
||||||
const expandedName = ref<string[]>([]);
|
const expandedName = ref<string[]>([]);
|
||||||
|
const configOptions = ref<GroupConfigOption[]>([]);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadConfigOptions();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -52,6 +57,44 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 监听任务完成事件,自动刷新当前分组数据
|
||||||
|
watch(
|
||||||
|
() => appState.groupDataRefreshTrigger,
|
||||||
|
() => {
|
||||||
|
// 检查是否需要刷新当前分组的数据
|
||||||
|
if (appState.lastCompletedTask && props.group) {
|
||||||
|
// 通过分组名称匹配
|
||||||
|
const isCurrentGroup = appState.lastCompletedTask.groupName === props.group.name;
|
||||||
|
|
||||||
|
const shouldRefresh =
|
||||||
|
appState.lastCompletedTask.taskType === "KEY_VALIDATION" ||
|
||||||
|
appState.lastCompletedTask.taskType === "KEY_IMPORT";
|
||||||
|
|
||||||
|
if (isCurrentGroup && shouldRefresh) {
|
||||||
|
// 刷新当前分组的统计数据
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听同步操作完成事件,自动刷新当前分组数据
|
||||||
|
watch(
|
||||||
|
() => appState.syncOperationTrigger,
|
||||||
|
() => {
|
||||||
|
// 检查是否需要刷新当前分组的数据
|
||||||
|
if (appState.lastSyncOperation && props.group) {
|
||||||
|
// 通过分组名称匹配
|
||||||
|
const isCurrentGroup = appState.lastSyncOperation.groupName === props.group.name;
|
||||||
|
|
||||||
|
if (isCurrentGroup) {
|
||||||
|
// 刷新当前分组的统计数据
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
if (!props.group?.id) {
|
if (!props.group?.id) {
|
||||||
stats.value = null;
|
stats.value = null;
|
||||||
@@ -68,6 +111,25 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadConfigOptions() {
|
||||||
|
try {
|
||||||
|
const options = await keysApi.getGroupConfigOptions();
|
||||||
|
configOptions.value = options || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取配置选项失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigDisplayName(key: string): string {
|
||||||
|
const option = configOptions.value.find(opt => opt.key === key);
|
||||||
|
return option?.name || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigDescription(key: string): string {
|
||||||
|
const option = configOptions.value.find(opt => opt.key === key);
|
||||||
|
return option?.description || "暂无说明";
|
||||||
|
}
|
||||||
|
|
||||||
function handleEdit() {
|
function handleEdit() {
|
||||||
showEditModal.value = true;
|
showEditModal.value = true;
|
||||||
}
|
}
|
||||||
@@ -295,27 +357,27 @@ function resetPage() {
|
|||||||
<n-grid :cols="2">
|
<n-grid :cols="2">
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item label="分组名称:">
|
<n-form-item label="分组名称:">
|
||||||
{{ group?.name || "-" }}
|
{{ group?.name }}
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item label="显示名称:">
|
<n-form-item label="显示名称:">
|
||||||
{{ group?.display_name || "-" }}
|
{{ group?.display_name }}
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item label="渠道类型:">
|
<n-form-item label="渠道类型:">
|
||||||
{{ group?.channel_type || "-" }}
|
{{ group?.channel_type }}
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="测试模型:">
|
|
||||||
{{ group?.test_model || "-" }}
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item label="排序:">
|
<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-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item v-if="group?.channel_type !== 'gemini'">
|
<n-grid-item v-if="group?.channel_type !== 'gemini'">
|
||||||
@@ -325,7 +387,9 @@ function resetPage() {
|
|||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item label="描述:">
|
<n-form-item label="描述:">
|
||||||
{{ group?.description || "-" }}
|
<div class="description-content">
|
||||||
|
{{ group?.description }}
|
||||||
|
</div>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
@@ -357,11 +421,29 @@ function resetPage() {
|
|||||||
>
|
>
|
||||||
<h4 class="section-title">高级配置</h4>
|
<h4 class="section-title">高级配置</h4>
|
||||||
<n-form label-placement="left">
|
<n-form label-placement="left">
|
||||||
<n-form-item
|
<n-form-item v-for="(value, key) in group?.config || {}" :key="key">
|
||||||
v-for="(value, key) in group?.config || {}"
|
<template #label>
|
||||||
:key="key"
|
<n-tooltip trigger="hover" :delay="300" placement="top">
|
||||||
:label="`${key}:`"
|
<template #trigger>
|
||||||
>
|
<span class="config-label">
|
||||||
|
{{ getConfigDisplayName(key) }}:
|
||||||
|
<n-icon size="14" class="config-help-icon">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A1.5,1.5 0 0,1 10.5,15.5A1.5,1.5 0 0,1 12,14A1.5,1.5 0 0,1 13.5,15.5A1.5,1.5 0 0,1 12,17M12,10.5C10.07,10.5 8.5,8.93 8.5,7A3.5,3.5 0 0,1 12,3.5A3.5,3.5 0 0,1 15.5,7C15.5,8.93 13.93,10.5 12,10.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="config-tooltip">
|
||||||
|
<div class="tooltip-title">{{ getConfigDisplayName(key) }}</div>
|
||||||
|
<div class="tooltip-description">{{ getConfigDescription(key) }}</div>
|
||||||
|
<div class="tooltip-key">配置键: {{ key }}</div>
|
||||||
|
</div>
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
{{ value || "-" }}
|
{{ value || "-" }}
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item v-if="group?.param_overrides" label="参数覆盖:" :span="2">
|
<n-form-item v-if="group?.param_overrides" label="参数覆盖:" :span="2">
|
||||||
@@ -521,4 +603,59 @@ function resetPage() {
|
|||||||
:deep(.n-form-item-feedback-wrapper) {
|
:deep(.n-form-item-feedback-wrapper) {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 描述内容样式 */
|
||||||
|
.description-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 20px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 配置项tooltip样式 */
|
||||||
|
.config-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-help-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label:hover .config-help-icon {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tooltip {
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description {
|
||||||
|
color: #e5e7eb;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-key {
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: "group-select", group: Group): void;
|
(e: "group-select", group: Group): void;
|
||||||
(e: "refresh"): void;
|
(e: "refresh"): void;
|
||||||
|
(e: "refresh-and-select", groupId: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -61,10 +62,15 @@ function openCreateGroupModal() {
|
|||||||
showGroupModal.value = true;
|
showGroupModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGroupCreated() {
|
function handleGroupCreated(_group: Group) {
|
||||||
showGroupModal.value = false;
|
showGroupModal.value = false;
|
||||||
emit("refresh");
|
emit("refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSwitchToGroup(groupId: number) {
|
||||||
|
// 创建成功后,通知父组件刷新并切换到新创建的分组
|
||||||
|
emit("refresh-and-select", groupId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -123,7 +129,11 @@ function handleGroupCreated() {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { keysApi } from "@/api/keys";
|
import { keysApi } from "@/api/keys";
|
||||||
import type { APIKey, Group, KeyStatus } from "@/types/models";
|
import type { APIKey, Group, KeyStatus } from "@/types/models";
|
||||||
import { appState } from "@/utils/app-state";
|
import { appState, triggerSyncOperationRefresh } from "@/utils/app-state";
|
||||||
import { getGroupDisplayName, maskKey } from "@/utils/display";
|
|
||||||
import { copy } from "@/utils/clipboard";
|
import { copy } from "@/utils/clipboard";
|
||||||
|
import { getGroupDisplayName, maskKey } from "@/utils/display";
|
||||||
import {
|
import {
|
||||||
AddCircleOutline,
|
AddCircleOutline,
|
||||||
AlertCircleOutline,
|
AlertCircleOutline,
|
||||||
@@ -96,6 +96,27 @@ watch([currentPage, pageSize, statusFilter], async () => {
|
|||||||
await loadKeys();
|
await loadKeys();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听任务完成事件,自动刷新密钥列表
|
||||||
|
watch(
|
||||||
|
() => appState.groupDataRefreshTrigger,
|
||||||
|
() => {
|
||||||
|
// 检查是否需要刷新当前分组的密钥列表
|
||||||
|
if (appState.lastCompletedTask && props.selectedGroup) {
|
||||||
|
// 通过分组名称匹配
|
||||||
|
const isCurrentGroup = appState.lastCompletedTask.groupName === props.selectedGroup.name;
|
||||||
|
|
||||||
|
const shouldRefresh =
|
||||||
|
appState.lastCompletedTask.taskType === "KEY_VALIDATION" ||
|
||||||
|
appState.lastCompletedTask.taskType === "KEY_IMPORT";
|
||||||
|
|
||||||
|
if (isCurrentGroup && shouldRefresh) {
|
||||||
|
// 刷新当前分组的密钥列表
|
||||||
|
loadKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 处理搜索输入的防抖
|
// 处理搜索输入的防抖
|
||||||
function handleSearchInput() {
|
function handleSearchInput() {
|
||||||
currentPage.value = 1; // 搜索时重置到第一页
|
currentPage.value = 1; // 搜索时重置到第一页
|
||||||
@@ -150,6 +171,15 @@ async function loadKeys() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理批量删除成功后的刷新
|
||||||
|
async function handleBatchDeleteSuccess() {
|
||||||
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
if (props.selectedGroup) {
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "BATCH_DELETE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyKey(key: KeyRow) {
|
async function copyKey(key: KeyRow) {
|
||||||
const success = await copy(key.key_value);
|
const success = await copy(key.key_value);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -180,6 +210,9 @@ async function testKey(_key: KeyRow) {
|
|||||||
closable: true,
|
closable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "TEST_SINGLE");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error("测试失败");
|
console.error("测试失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -213,6 +246,8 @@ async function restoreKey(key: KeyRow) {
|
|||||||
try {
|
try {
|
||||||
await keysApi.restoreKeys(props.selectedGroup.id, key.key_value);
|
await keysApi.restoreKeys(props.selectedGroup.id, key.key_value);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "RESTORE_SINGLE");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error("恢复失败");
|
console.error("恢复失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -244,6 +279,8 @@ async function deleteKey(key: KeyRow) {
|
|||||||
try {
|
try {
|
||||||
await keysApi.deleteKeys(props.selectedGroup.id, key.key_value);
|
await keysApi.deleteKeys(props.selectedGroup.id, key.key_value);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "DELETE_SINGLE");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error("删除失败");
|
console.error("删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -335,6 +372,8 @@ async function restoreAllInvalid() {
|
|||||||
try {
|
try {
|
||||||
await keysApi.restoreAllInvalidKeys(props.selectedGroup.id);
|
await keysApi.restoreAllInvalidKeys(props.selectedGroup.id);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "RESTORE_ALL_INVALID");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error("恢复失败");
|
console.error("恢复失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -387,6 +426,8 @@ async function clearAllInvalid() {
|
|||||||
const { data } = await keysApi.clearAllInvalidKeys(props.selectedGroup.id);
|
const { data } = await keysApi.clearAllInvalidKeys(props.selectedGroup.id);
|
||||||
window.$message.success(data?.message || "清除成功");
|
window.$message.success(data?.message || "清除成功");
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
|
// 触发同步操作刷新
|
||||||
|
triggerSyncOperationRefresh(props.selectedGroup.name, "CLEAR_ALL_INVALID");
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error("删除失败");
|
console.error("删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -610,7 +651,7 @@ function resetPage() {
|
|||||||
v-model:show="deleteDialogShow"
|
v-model:show="deleteDialogShow"
|
||||||
:group-id="selectedGroup.id"
|
:group-id="selectedGroup.id"
|
||||||
:group-name="getGroupDisplayName(selectedGroup!)"
|
:group-name="getGroupDisplayName(selectedGroup!)"
|
||||||
@success="loadKeys"
|
@success="handleBatchDeleteSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@@ -3,9 +3,33 @@ import { reactive } from "vue";
|
|||||||
interface AppState {
|
interface AppState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
taskPollingTrigger: number;
|
taskPollingTrigger: number;
|
||||||
|
groupDataRefreshTrigger: number;
|
||||||
|
syncOperationTrigger: number;
|
||||||
|
lastCompletedTask?: {
|
||||||
|
groupName: string;
|
||||||
|
taskType: string;
|
||||||
|
finishedAt: string;
|
||||||
|
};
|
||||||
|
lastSyncOperation?: {
|
||||||
|
groupName: string;
|
||||||
|
operationType: string;
|
||||||
|
finishedAt: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appState = reactive<AppState>({
|
export const appState = reactive<AppState>({
|
||||||
loading: false,
|
loading: false,
|
||||||
taskPollingTrigger: 0,
|
taskPollingTrigger: 0,
|
||||||
|
groupDataRefreshTrigger: 0,
|
||||||
|
syncOperationTrigger: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 触发同步操作后的数据刷新
|
||||||
|
export function triggerSyncOperationRefresh(groupName: string, operationType: string) {
|
||||||
|
appState.lastSyncOperation = {
|
||||||
|
groupName,
|
||||||
|
operationType,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
appState.syncOperationTrigger++;
|
||||||
|
}
|
||||||
|
@@ -7,7 +7,7 @@ import { NSpace } from "naive-ui";
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<n-space vertical size="large">
|
<n-space vertical size="large">
|
||||||
<n-space vertical size="large">
|
<n-space vertical size="large" style="gap: 24px">
|
||||||
<base-info-card />
|
<base-info-card />
|
||||||
<line-chart class="dashboard-chart" />
|
<line-chart class="dashboard-chart" />
|
||||||
</n-space>
|
</n-space>
|
||||||
|
@@ -51,6 +51,15 @@ async function handleGroupRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGroupRefreshAndSelect(targetGroupId: number) {
|
||||||
|
await loadGroups();
|
||||||
|
// 刷新完成后,切换到指定的分组
|
||||||
|
const targetGroup = groups.value.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
handleGroupSelect(targetGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleGroupDelete(deletedGroup: Group) {
|
function handleGroupDelete(deletedGroup: Group) {
|
||||||
// 从分组列表中移除已删除的分组
|
// 从分组列表中移除已删除的分组
|
||||||
groups.value = groups.value.filter(g => g.id !== deletedGroup.id);
|
groups.value = groups.value.filter(g => g.id !== deletedGroup.id);
|
||||||
@@ -71,6 +80,7 @@ function handleGroupDelete(deletedGroup: Group) {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
@group-select="handleGroupSelect"
|
@group-select="handleGroupSelect"
|
||||||
@refresh="handleGroupRefresh"
|
@refresh="handleGroupRefresh"
|
||||||
|
@refresh-and-select="handleGroupRefreshAndSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user