group add
This commit is contained in:
7
web/pnpm-lock.yaml
generated
7
web/pnpm-lock.yaml
generated
@@ -5,6 +5,9 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@vicons/ionicons5':
|
||||||
|
specifier: ^0.13.0
|
||||||
|
version: 0.13.0
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
@@ -815,6 +818,10 @@ packages:
|
|||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@vicons/ionicons5@0.13.0:
|
||||||
|
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@vitejs/plugin-vue@5.2.4(vite@6.3.5)(vue@3.5.17):
|
/@vitejs/plugin-vue@5.2.4(vite@6.3.5)(vue@3.5.17):
|
||||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GlobalProviders from "@/components/GlobalProviders.vue";
|
import GlobalProviders from "@/components/GlobalProviders.vue";
|
||||||
import GlobalTaskProgressBar from "@/components/GlobalTaskProgressBar.vue";
|
|
||||||
import Layout from "@/components/Layout.vue";
|
import Layout from "@/components/Layout.vue";
|
||||||
import { useAuthKey } from "@/services/auth";
|
import { useAuthKey } from "@/services/auth";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
@@ -16,7 +15,7 @@ const isLoggedIn = computed(() => !!authKey.value);
|
|||||||
<router-view v-else key="auth" />
|
<router-view v-else key="auth" />
|
||||||
|
|
||||||
<!-- 全局任务进度条 -->
|
<!-- 全局任务进度条 -->
|
||||||
<global-task-progress-bar />
|
<!-- <global-task-progress-bar /> -->
|
||||||
</div>
|
</div>
|
||||||
</global-providers>
|
</global-providers>
|
||||||
</template>
|
</template>
|
||||||
|
@@ -1,637 +1,109 @@
|
|||||||
import type { APIKey, Group, GroupStats, TaskInfo } from "@/types/models";
|
import type { APIKey, Group, GroupConfigOption, TaskInfo } from "@/types/models";
|
||||||
|
import http from "@/utils/http";
|
||||||
// Mock数据 - 实际开发时应该从后端获取
|
|
||||||
const mockGroups: Group[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "",
|
|
||||||
description: "",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "gemini-backup",
|
|
||||||
display_name: "Gemini备用组",
|
|
||||||
description: "Gemini备用API组",
|
|
||||||
sort: 2,
|
|
||||||
channel_type: "gemini",
|
|
||||||
upstreams: [{ url: "https://generativelanguage.googleapis.com", weight: 1 }],
|
|
||||||
config: {
|
|
||||||
test_model: "gemini-pro",
|
|
||||||
param_overrides: {},
|
|
||||||
request_timeout: 25000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-02T00:00:00Z",
|
|
||||||
updated_at: "2024-01-02T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "silicon-test",
|
|
||||||
display_name: "Silicon测试组",
|
|
||||||
description: "Silicon Flow测试API组",
|
|
||||||
sort: 3,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [{ url: "https://api.siliconflow.cn", weight: 1 }],
|
|
||||||
config: {
|
|
||||||
test_model: "qwen-turbo",
|
|
||||||
param_overrides: {},
|
|
||||||
request_timeout: 20000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 12,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 13,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 14,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 15,
|
|
||||||
name: "openai-main",
|
|
||||||
display_name: "OpenAI主组",
|
|
||||||
description: "OpenAI主要API组",
|
|
||||||
sort: 1,
|
|
||||||
channel_type: "openai",
|
|
||||||
upstreams: [
|
|
||||||
{ url: "https://api.openai.com", weight: 1 },
|
|
||||||
{ url: "https://api.openai.com/v1", weight: 2 },
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
test_model: "gpt-3.5-turbo",
|
|
||||||
param_overrides: { temperature: 0.7 },
|
|
||||||
request_timeout: 30000,
|
|
||||||
},
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockAPIKeys: APIKey[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sk-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 1250,
|
|
||||||
failure_count: 3,
|
|
||||||
last_used_at: "2024-01-01T12:00:00Z",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sk-abcdef1234567890abcdef1234567890",
|
|
||||||
status: "inactive",
|
|
||||||
request_count: 890,
|
|
||||||
failure_count: 15,
|
|
||||||
last_used_at: "2024-01-01T10:00:00Z",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sk-fedcba0987654321fedcba0987654321",
|
|
||||||
status: "active",
|
|
||||||
request_count: 2100,
|
|
||||||
failure_count: 1,
|
|
||||||
last_used_at: "2024-01-01T14:00:00Z",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "gk-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 450,
|
|
||||||
failure_count: 2,
|
|
||||||
last_used_at: "2024-01-02T11:00:00Z",
|
|
||||||
created_at: "2024-01-02T00:00:00Z",
|
|
||||||
updated_at: "2024-01-02T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sf-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 320,
|
|
||||||
failure_count: 0,
|
|
||||||
last_used_at: "2024-01-03T09:00:00Z",
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sf-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 320,
|
|
||||||
failure_count: 0,
|
|
||||||
last_used_at: "2024-01-03T09:00:00Z",
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sf-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 320,
|
|
||||||
failure_count: 0,
|
|
||||||
last_used_at: "2024-01-03T09:00:00Z",
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sf-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 320,
|
|
||||||
failure_count: 0,
|
|
||||||
last_used_at: "2024-01-03T09:00:00Z",
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
group_id: 1,
|
|
||||||
key_value: "sf-1234567890abcdef1234567890abcdef",
|
|
||||||
status: "active",
|
|
||||||
request_count: 320,
|
|
||||||
failure_count: 0,
|
|
||||||
last_used_at: "2024-01-03T09:00:00Z",
|
|
||||||
created_at: "2024-01-03T00:00:00Z",
|
|
||||||
updated_at: "2024-01-03T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let mockTaskInfo: TaskInfo = {
|
|
||||||
is_running: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const keysApi = {
|
export const keysApi = {
|
||||||
// 获取所有分组
|
// 获取所有分组
|
||||||
async getGroups(): Promise<Group[]> {
|
async getGroups(): Promise<Group[]> {
|
||||||
// 模拟网络延迟
|
const res = await http.get("/groups");
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
return res.data || [];
|
||||||
return mockGroups;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取分组信息
|
|
||||||
async getGroup(groupId: number): Promise<Group | null> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
return mockGroups.find(g => g.id === groupId) || null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 创建分组
|
// 创建分组
|
||||||
async createGroup(group: Partial<Group>): Promise<Group> {
|
async createGroup(group: Partial<Group>): Promise<Group> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
const res = await http.post("/groups", group);
|
||||||
const newGroup: Group = {
|
return res.data;
|
||||||
id: Math.max(...mockGroups.map(g => g.id)) + 1,
|
|
||||||
name: group.name || "",
|
|
||||||
display_name: group.display_name || "",
|
|
||||||
description: group.description || "",
|
|
||||||
sort: group.sort || 0,
|
|
||||||
channel_type: group.channel_type || "openai",
|
|
||||||
upstreams: group.upstreams || [],
|
|
||||||
config: group.config || {},
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
mockGroups.push(newGroup);
|
|
||||||
return newGroup;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新分组
|
// 更新分组
|
||||||
async updateGroup(groupId: number, group: Partial<Group>): Promise<Group> {
|
async updateGroup(groupId: number, group: Partial<Group>): Promise<void> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
const res = await http.put(`/groups/${groupId}`, group);
|
||||||
const index = mockGroups.findIndex(g => g.id === groupId);
|
return res.data;
|
||||||
if (index === -1) {
|
|
||||||
throw new Error("分组不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
mockGroups[index] = {
|
|
||||||
...mockGroups[index],
|
|
||||||
...group,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
return mockGroups[index];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除分组
|
// 删除分组
|
||||||
async deleteGroup(groupId: number): Promise<void> {
|
deleteGroup(groupId: number): Promise<void> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
return http.delete(`/groups/${groupId}`);
|
||||||
const index = mockGroups.findIndex(g => g.id === groupId);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error("分组不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
mockGroups.splice(index, 1);
|
|
||||||
// 同时删除该分组的所有密钥
|
|
||||||
const keyIndexes = mockAPIKeys
|
|
||||||
.map((key, i) => (key.group_id === groupId ? i : -1))
|
|
||||||
.filter(i => i !== -1);
|
|
||||||
keyIndexes.reverse().forEach(i => mockAPIKeys.splice(i, 1));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取分组统计信息
|
// 获取分组统计信息
|
||||||
async getGroupStats(groupId: number): Promise<GroupStats> {
|
async getGroupStats(groupId: number): Promise<GroupStats> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
const keys = mockAPIKeys.filter(k => k.group_id === groupId);
|
return {};
|
||||||
const activeKeys = keys.filter(k => k.status === "active");
|
},
|
||||||
|
|
||||||
return {
|
// 获取分组可配置参数
|
||||||
total_keys: keys.length,
|
async getGroupConfigOptions(): Promise<GroupConfigOption[]> {
|
||||||
active_keys: activeKeys.length,
|
const res = await http.get("/groups/config-options");
|
||||||
requests_1h: Math.floor(Math.random() * 100),
|
return res.data || [];
|
||||||
requests_24h: keys.reduce((sum, key) => sum + key.request_count, 0),
|
|
||||||
requests_7d: Math.floor(keys.reduce((sum, key) => sum + key.request_count, 0) * 7.2),
|
|
||||||
failure_rate_24h:
|
|
||||||
keys.length > 0
|
|
||||||
? (keys.reduce((sum, key) => sum + key.failure_count, 0) /
|
|
||||||
keys.reduce((sum, key) => sum + key.request_count, 1)) *
|
|
||||||
100
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取分组的密钥列表
|
// 获取分组的密钥列表
|
||||||
async getGroupKeys(
|
async getGroupKeys(params: {
|
||||||
groupId: number,
|
group_id: number;
|
||||||
page = 1,
|
|
||||||
size = 10,
|
|
||||||
filter?: string
|
|
||||||
): Promise<{
|
|
||||||
data: APIKey[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
page: number;
|
||||||
size: number;
|
page_size: number;
|
||||||
}> {
|
key?: string;
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
status?: "active" | "inactive";
|
||||||
let keys = mockAPIKeys.filter(k => k.group_id === groupId);
|
}): Promise<{
|
||||||
|
items: APIKey[];
|
||||||
if (filter === "valid") {
|
pagination: {
|
||||||
keys = keys.filter(k => k.status === "active");
|
total_items: number;
|
||||||
} else if (filter === "invalid") {
|
|
||||||
keys = keys.filter(k => k.status !== "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = (page - 1) * size;
|
|
||||||
const end = start + size;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: keys.slice(start, end),
|
|
||||||
total: keys.length,
|
|
||||||
page,
|
|
||||||
size,
|
|
||||||
};
|
};
|
||||||
},
|
}> {
|
||||||
|
const res = await http.get("/keys", { params });
|
||||||
// 获取密钥列表(简化方法)
|
return res.data;
|
||||||
async getKeys(groupId: number): Promise<APIKey[]> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
return mockAPIKeys.filter(k => k.group_id === groupId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 批量添加密钥
|
// 批量添加密钥
|
||||||
async addMultipleKeys(
|
async addMultipleKeys(
|
||||||
groupId: number,
|
group_id: number,
|
||||||
keysText: string
|
keys_text: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
added_count: number;
|
added_count: number;
|
||||||
ignored_count: number;
|
ignored_count: number;
|
||||||
total_in_group: number;
|
total_in_group: number;
|
||||||
}> {
|
}> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
const res = await http.post("/keys/add-multiple", {
|
||||||
|
group_id,
|
||||||
// 解析密钥文本
|
keys_text,
|
||||||
const keys = this.parseKeysText(keysText);
|
|
||||||
const existingKeys = mockAPIKeys.filter(k => k.group_id === groupId).map(k => k.key_value);
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
let ignoredCount = 0;
|
|
||||||
|
|
||||||
keys.forEach(key => {
|
|
||||||
if (existingKeys.includes(key)) {
|
|
||||||
ignoredCount++;
|
|
||||||
} else {
|
|
||||||
const newKey: APIKey = {
|
|
||||||
id: Math.max(...mockAPIKeys.map(k => k.id)) + 1,
|
|
||||||
group_id: groupId,
|
|
||||||
key_value: key,
|
|
||||||
status: "active",
|
|
||||||
request_count: 0,
|
|
||||||
failure_count: 0,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
mockAPIKeys.push(newKey);
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return res.data;
|
||||||
return {
|
|
||||||
added_count: addedCount,
|
|
||||||
ignored_count: ignoredCount,
|
|
||||||
total_in_group: mockAPIKeys.filter(k => k.group_id === groupId).length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量添加密钥(别名方法)
|
|
||||||
async batchAddKeys(_keysText: string): Promise<void> {
|
|
||||||
// 模拟批量导入,触发全局任务
|
|
||||||
mockTaskInfo = {
|
|
||||||
is_running: true,
|
|
||||||
task_name: "批量导入密钥",
|
|
||||||
group_id: 1,
|
|
||||||
group_name: "当前分组",
|
|
||||||
processed: 0,
|
|
||||||
total: 100,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
message: "正在导入密钥...",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 10秒后完成任务
|
|
||||||
setTimeout(() => {
|
|
||||||
mockTaskInfo = { is_running: false };
|
|
||||||
}, 10000);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 解析密钥文本
|
|
||||||
parseKeysText(text: string): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
|
|
||||||
// 尝试解析JSON数组
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed.filter(key => typeof key === "string" && key.trim().length > 0);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 不是JSON,继续其他解析方式
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按行分割,然后按常见分隔符分割
|
|
||||||
const lines = text.split(/\r?\n/);
|
|
||||||
lines.forEach(line => {
|
|
||||||
// 按逗号、分号、空格分割
|
|
||||||
const parts = line.split(/[,;\s]+/).filter(part => part.trim().length > 0);
|
|
||||||
keys.push(...parts);
|
|
||||||
});
|
|
||||||
|
|
||||||
return keys.filter(key => key.trim().length > 0);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 测试单个密钥
|
// 测试单个密钥
|
||||||
async testKey(_keyId: number): Promise<{ success: boolean; message: string }> {
|
async testKeys(
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
group_id: number,
|
||||||
// 模拟测试结果
|
keys_text: string
|
||||||
const success = Math.random() > 0.2; // 80% 成功率
|
): Promise<
|
||||||
return {
|
{
|
||||||
success,
|
key_value: string;
|
||||||
message: success ? "密钥测试成功" : "密钥测试失败:权限不足或密钥无效",
|
is_valid: boolean;
|
||||||
};
|
error: string;
|
||||||
},
|
}[]
|
||||||
|
> {
|
||||||
// 恢复密钥
|
const res = await http.post("/keys/test-multiple", {
|
||||||
async restoreKey(keyId: number): Promise<void> {
|
group_id,
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
keys_text,
|
||||||
const key = mockAPIKeys.find(k => k.id === keyId);
|
});
|
||||||
if (key) {
|
return res.data;
|
||||||
key.status = "active";
|
|
||||||
key.updated_at = new Date().toISOString();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除密钥
|
// 删除密钥
|
||||||
async deleteKey(keyId: number): Promise<void> {
|
deleteKeys(group_id: number, keys_text: string): Promise<void> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
return http.post("/keys/delete-multiple", {
|
||||||
const index = mockAPIKeys.findIndex(k => k.id === keyId);
|
group_id,
|
||||||
if (index !== -1) {
|
keys_text,
|
||||||
mockAPIKeys.splice(index, 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 恢复所有无效密钥
|
|
||||||
async restoreAllInvalidKeys(groupId: number): Promise<void> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
mockAPIKeys.forEach(key => {
|
|
||||||
if (key.group_id === groupId && key.status !== "active") {
|
|
||||||
key.status = "active";
|
|
||||||
key.updated_at = new Date().toISOString();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 恢复所有无效密钥
|
||||||
|
restoreAllInvalidKeys(group_id: number): Promise<void> {
|
||||||
|
return http.post("/keys/restore-all-invalid", { group_id });
|
||||||
|
},
|
||||||
|
|
||||||
// 清空所有无效密钥
|
// 清空所有无效密钥
|
||||||
async clearAllInvalidKeys(groupId: number): Promise<void> {
|
clearAllInvalidKeys(group_id: number): Promise<void> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
return http.post("/keys/clear-all-invalid", { group_id });
|
||||||
for (let i = mockAPIKeys.length - 1; i >= 0; i--) {
|
|
||||||
if (mockAPIKeys[i].group_id === groupId && mockAPIKeys[i].status !== "active") {
|
|
||||||
mockAPIKeys.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 导出密钥
|
// 导出密钥
|
||||||
@@ -639,100 +111,26 @@ export const keysApi = {
|
|||||||
groupId: number,
|
groupId: number,
|
||||||
filter: "all" | "valid" | "invalid" = "all"
|
filter: "all" | "valid" | "invalid" = "all"
|
||||||
): Promise<{ keys: string[] }> {
|
): Promise<{ keys: string[] }> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
const params: any = { filter };
|
||||||
let keys = mockAPIKeys.filter(k => k.group_id === groupId);
|
const res = await http.get(`/groups/${groupId}/keys/export`, { params });
|
||||||
|
return res.data;
|
||||||
if (filter === "valid") {
|
|
||||||
keys = keys.filter(k => k.status === "active");
|
|
||||||
} else if (filter === "invalid") {
|
|
||||||
keys = keys.filter(k => k.status !== "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
keys: keys.map(k => k.key_value),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 验证分组密钥
|
// 验证分组密钥
|
||||||
async validateGroupKeys(groupId: number): Promise<void> {
|
async validateGroupKeys(groupId: number): Promise<{
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
is_running: boolean;
|
||||||
|
group_name: string;
|
||||||
if (mockTaskInfo.is_running) {
|
processed: number;
|
||||||
throw new Error("已有验证任务正在运行");
|
total: number;
|
||||||
}
|
started_at: string;
|
||||||
|
}> {
|
||||||
const group = mockGroups.find(g => g.id === groupId);
|
const res = await http.post("/keys/validate-group", { group_id: groupId });
|
||||||
const keys = mockAPIKeys.filter(k => k.group_id === groupId);
|
return res.data;
|
||||||
|
|
||||||
mockTaskInfo = {
|
|
||||||
is_running: true,
|
|
||||||
task_name: "key_validation",
|
|
||||||
group_id: groupId,
|
|
||||||
group_name: group?.display_name || group?.name || "",
|
|
||||||
processed: 0,
|
|
||||||
total: keys.length,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 模拟异步验证过程
|
|
||||||
setTimeout(() => {
|
|
||||||
mockTaskInfo = { is_running: false };
|
|
||||||
}, 10000); // 10秒后完成
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取任务状态
|
// 获取任务状态
|
||||||
async getTaskStatus(): Promise<TaskInfo> {
|
async getTaskStatus(): Promise<TaskInfo> {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
const res = await http.get("/tasks/status");
|
||||||
|
return res.data;
|
||||||
if (
|
|
||||||
mockTaskInfo.is_running &&
|
|
||||||
mockTaskInfo.processed !== undefined &&
|
|
||||||
mockTaskInfo.total !== undefined
|
|
||||||
) {
|
|
||||||
// 模拟进度更新
|
|
||||||
if (mockTaskInfo.processed < mockTaskInfo.total) {
|
|
||||||
mockTaskInfo.processed = Math.min(mockTaskInfo.processed + 1, mockTaskInfo.total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockTaskInfo;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量切换密钥状态
|
|
||||||
async batchToggleKeys(_keyIds: string[], _status: 0 | 1): Promise<void> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
// 模拟批量操作成功
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量删除密钥
|
|
||||||
async batchDeleteKeys(_keyIds: string[]): Promise<void> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
|
||||||
// 模拟批量删除成功
|
|
||||||
},
|
|
||||||
|
|
||||||
// 切换单个密钥状态
|
|
||||||
async toggleKeyStatus(_keyId: string, _status: 0 | 1): Promise<void> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
// 模拟切换状态成功
|
|
||||||
},
|
|
||||||
|
|
||||||
// 删除单个密钥
|
|
||||||
async deleteKeyById(_keyId: string): Promise<void> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 400));
|
|
||||||
// 模拟删除成功
|
|
||||||
},
|
|
||||||
|
|
||||||
// 验证密钥
|
|
||||||
async validateKeys(_groupId: number): Promise<{ valid_count: number; invalid_count: number }> {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
// 模拟验证结果
|
|
||||||
const validCount = Math.floor(Math.random() * 10) + 5;
|
|
||||||
const invalidCount = Math.floor(Math.random() * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid_count: validCount,
|
|
||||||
invalid_count: invalidCount,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@@ -17,11 +17,15 @@ export interface SettingCategory {
|
|||||||
export type SettingsUpdatePayload = Record<string, string | number>;
|
export type SettingsUpdatePayload = Record<string, string | number>;
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
getSettings: async (): Promise<SettingCategory[]> => {
|
async getSettings(): Promise<SettingCategory[]> {
|
||||||
const response = await http.get("/settings");
|
const response = await http.get("/settings");
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
},
|
},
|
||||||
updateSettings: (data: SettingsUpdatePayload): Promise<void> => {
|
updateSettings(data: SettingsUpdatePayload): Promise<void> {
|
||||||
return http.put("/settings", data);
|
return http.put("/settings", data);
|
||||||
},
|
},
|
||||||
|
async getChannelTypes(): Promise<string[]> {
|
||||||
|
const response = await http.get("/channel-types");
|
||||||
|
return response.data || [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
562
web/src/components/keys/GroupFormModal.vue
Normal file
562
web/src/components/keys/GroupFormModal.vue
Normal 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>
|
@@ -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, GroupStats } from "@/types/models";
|
import type { Group, GroupStats } from "@/types/models";
|
||||||
|
import { getGroupDisplayName } from "@/utils/display";
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NCard,
|
NCard,
|
||||||
@@ -14,7 +15,6 @@ import {
|
|||||||
NTag,
|
NTag,
|
||||||
useMessage,
|
useMessage,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { getGroupDisplayName } from "@/utils/display";
|
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -47,8 +47,6 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
stats.value = await keysApi.getGroupStats(props.group.id);
|
stats.value = await keysApi.getGroupStats(props.group.id);
|
||||||
} catch (_error) {
|
|
||||||
// 错误已记录
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { keysApi } from "@/api/keys";
|
|
||||||
import type { Group } from "@/types/models";
|
import type { Group } from "@/types/models";
|
||||||
import { getGroupDisplayName } from "@/utils/display";
|
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 { computed, ref } from "vue";
|
||||||
|
import GroupFormModal from "./GroupFormModal.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@@ -23,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const searchText = ref("");
|
const searchText = ref("");
|
||||||
const message = useMessage();
|
const showGroupModal = ref(false);
|
||||||
|
|
||||||
// 过滤后的分组列表
|
// 过滤后的分组列表
|
||||||
const filteredGroups = computed(() => {
|
const filteredGroups = computed(() => {
|
||||||
@@ -54,29 +55,13 @@ function getChannelTagType(channelType: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的创建分组功能(演示用)
|
function openCreateGroupModal() {
|
||||||
async function createDemoGroup() {
|
showGroupModal.value = true;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
message.success(`创建分组成功: ${newGroup.display_name}`);
|
function handleGroupCreated() {
|
||||||
emit("refresh");
|
showGroupModal.value = false;
|
||||||
} catch (_error) {
|
emit("refresh");
|
||||||
// 错误已记录
|
|
||||||
message.error("创建分组失败");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -87,11 +72,7 @@ async function createDemoGroup() {
|
|||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
<n-input v-model:value="searchText" placeholder="搜索分组名称..." size="small" clearable>
|
<n-input v-model:value="searchText" placeholder="搜索分组名称..." size="small" clearable>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<n-icon :component="Search" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,16 +112,15 @@ async function createDemoGroup() {
|
|||||||
|
|
||||||
<!-- 添加分组按钮 -->
|
<!-- 添加分组按钮 -->
|
||||||
<div class="add-section">
|
<div class="add-section">
|
||||||
<n-button type="primary" size="small" block @click="createDemoGroup">
|
<n-button type="primary" size="small" block @click="openCreateGroupModal">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<n-icon :component="Add" />
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
</template>
|
||||||
创建分组
|
创建分组
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
<group-form-modal v-model:show="showGroupModal" @success="handleGroupCreated" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@@ -293,7 +293,7 @@ async function validateAllKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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}个`);
|
window.$message.success(`验证完成: 有效${result.valid_count}个,无效${result.invalid_count}个`);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// 错误已记录
|
// 错误已记录
|
||||||
|
@@ -20,17 +20,26 @@ export interface UpstreamInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
id: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description: string;
|
description: string;
|
||||||
sort: number;
|
sort: number;
|
||||||
|
test_model: string;
|
||||||
channel_type: "openai" | "gemini";
|
channel_type: "openai" | "gemini";
|
||||||
upstreams: UpstreamInfo[];
|
upstreams: UpstreamInfo[];
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
api_keys?: APIKey[];
|
api_keys?: APIKey[];
|
||||||
created_at: string;
|
param_overrides: any;
|
||||||
updated_at: string;
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupConfigOption {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
default_value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupStats {
|
export interface GroupStats {
|
||||||
|
@@ -22,9 +22,6 @@ async function loadGroups() {
|
|||||||
if (groups.value.length > 0 && !selectedGroup.value) {
|
if (groups.value.length > 0 && !selectedGroup.value) {
|
||||||
selectedGroup.value = groups.value[0];
|
selectedGroup.value = groups.value[0];
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
|
||||||
// 错误已记录
|
|
||||||
window.$message.error("加载分组失败");
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user