test: web

This commit is contained in:
tbphp
2025-07-01 22:47:25 +08:00
parent c447e3ad0b
commit dcb862b11a
50 changed files with 5059 additions and 689 deletions

View File

@@ -1,10 +1,25 @@
<!doctype html>
<html lang="en">
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<title>GPT Load - 负载均衡管理系统</title>
<style>
/* 防止页面加载时出现布局闪烁 */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
}
#app {
width: 100%;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>

288
web/package-lock.json generated
View File

@@ -8,9 +8,12 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@types/lodash-es": "^4.17.12",
"axios": "^1.10.0",
"echarts": "^5.6.0",
"element-plus": "^2.10.2",
"lodash-es": "^4.17.21",
"pinia": "^3.0.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
@@ -19,6 +22,7 @@
"@types/node": "^24.0.7",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"postcss": "^8.5.6",
"typescript": "~5.8.3",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
@@ -538,6 +542,15 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@heroicons/vue/-/vue-2.2.0.tgz",
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
"license": "MIT",
"peerDependencies": {
"vue": ">= 3"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -1326,6 +1339,18 @@
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1677,6 +1702,269 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",

View File

@@ -9,9 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@types/lodash-es": "^4.17.12",
"axios": "^1.10.0",
"echarts": "^5.6.0",
"element-plus": "^2.10.2",
"lodash-es": "^4.17.21",
"pinia": "^3.0.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
@@ -20,6 +23,7 @@
"@types/node": "^24.0.7",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"postcss": "^8.5.6",
"typescript": "~5.8.3",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"

View File

@@ -1,8 +1,20 @@
<template>
<router-view />
<div id="app">
<router-view />
</div>
</template>
<script setup lang="ts">
// App.vue 现在只需要渲染路由视图
// 路由器会决定是渲染 Login 组件还是 MainLayout 组件
console.log("App.vue loaded");
</script>
<style>
#app {
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
}
</style>

View File

@@ -1,6 +1,11 @@
import request from './index';
import type { DashboardStats } from '@/types/models';
export const getDashboardStats = (): Promise<DashboardStats> => {
return request.get('/dashboard/stats');
export const getDashboardData = (timeRange: string, groupId: number | null): Promise<DashboardStats> => {
const params = new URLSearchParams();
params.append('time_range', timeRange);
if (groupId) {
params.append('group_id', groupId.toString());
}
return request.get(`/dashboard/data?${params.toString()}`);
};

View File

@@ -20,7 +20,7 @@ export const fetchGroup = (id: string): Promise<Group> => {
* 创建一个新的分组
* @param groupData 新分组的数据
*/
export const createGroup = (groupData: Omit<Group, 'id' | 'created_at' | 'updated_at'>): Promise<Group> => {
export const createGroup = (groupData: Omit<Group, 'id' | 'created_at' | 'updated_at' | 'api_keys'>): Promise<Group> => {
return apiClient.post('/groups', groupData).then(res => res.data.data);
};
@@ -29,7 +29,7 @@ export const createGroup = (groupData: Omit<Group, 'id' | 'created_at' | 'update
* @param id 分组ID
* @param groupData 要更新的数据
*/
export const updateGroup = (id: string, groupData: Partial<Omit<Group, 'id' | 'created_at' | 'updated_at'>>): Promise<Group> => {
export const updateGroup = (id: string, groupData: Partial<Omit<Group, 'id' | 'created_at' | 'updated_at' | 'api_keys'>>): Promise<Group> => {
return apiClient.put(`/groups/${id}`, groupData).then(res => res.data.data);
};

View File

@@ -1,12 +1,12 @@
import apiClient from './index';
import type { Key } from '../types/models';
import apiClient from "./index";
import type { Key } from "../types/models";
/**
* 获取指定分组下的所有密钥列表
* @param groupId 分组ID
*/
export const fetchKeysInGroup = (groupId: string): Promise<Key[]> => {
return apiClient.get(`/groups/${groupId}/keys`).then(res => res.data.data);
return apiClient.get(`/groups/${groupId}/keys`).then((res) => res.data.data);
};
/**
@@ -14,8 +14,21 @@ export const fetchKeysInGroup = (groupId: string): Promise<Key[]> => {
* @param groupId 分组ID
* @param keyData 新密钥的数据
*/
export const createKey = (groupId: string, keyData: Omit<Key, 'id' | 'group_id' | 'usage' | 'created_at' | 'updated_at'>): Promise<Key> => {
return apiClient.post(`/groups/${groupId}/keys`, keyData).then(res => res.data.data);
export const createKey = (
groupId: string,
keyData: Omit<
Key,
| "id"
| "group_id"
| "created_at"
| "updated_at"
| "request_count"
| "failure_count"
>
): Promise<Key> => {
return apiClient
.post(`/groups/${groupId}/keys`, keyData)
.then((res) => res.data.data);
};
/**
@@ -23,8 +36,8 @@ export const createKey = (groupId: string, keyData: Omit<Key, 'id' | 'group_id'
* @param id 密钥ID
* @param keyData 要更新的数据
*/
export const updateKey = (id: string, keyData: Partial<Omit<Key, 'id' | 'group_id' | 'usage' | 'created_at' | 'updated_at'>>): Promise<Key> => {
return apiClient.put(`/keys/${id}`, keyData).then(res => res.data.data);
export const updateKey = (id: string, keyData: Partial<Key>): Promise<Key> => {
return apiClient.put(`/keys/${id}`, keyData).then((res) => res.data.data);
};
/**
@@ -32,5 +45,27 @@ export const updateKey = (id: string, keyData: Partial<Omit<Key, 'id' | 'group_i
* @param id 密钥ID
*/
export const deleteKey = (id: string): Promise<void> => {
return apiClient.delete(`/keys/${id}`).then(res => res.data);
};
return apiClient.delete(`/keys/${id}`).then((res) => res.data);
};
/**
* 批量更新密钥
* @param ids 密钥ID列表
* @param data 要更新的数据
*/
export const batchUpdateKeys = (
ids: string[],
data: Partial<Key>
): Promise<void> => {
return apiClient
.post("/keys/batch-update", { ids, data })
.then((res) => res.data);
};
/**
* 批量删除密钥
* @param ids 密钥ID列表
*/
export const batchDeleteKeys = (ids: string[]): Promise<void> => {
return apiClient.post("/keys/batch-delete", { ids }).then((res) => res.data);
};

View File

@@ -1,10 +1,22 @@
import request from './index';
import type { Setting } from '@/types/models';
import type { SettingCategory, SystemSettings } from '@/types/models';
export function getSettings() {
return request.get<Setting[]>('/settings');
// A generic function to get settings for a specific category
export function getSettings<T>(category: SettingCategory) {
// The backend API would need to support this, e.g., /api/settings/system
return request.get<T>(`/settings/${category}`);
}
export function updateSettings(settings: Setting[]) {
return request.put('/settings', settings);
// A generic function to update settings for a specific category
export function updateSettings<T>(category: SettingCategory, settings: T) {
return request.put(`/settings/${category}`, settings);
}
// Specific functions for system settings as an example
export function getSystemSettings() {
return getSettings<SystemSettings>('system');
}
export function updateSystemSettings(settings: SystemSettings) {
return updateSettings('system', settings);
}

View File

@@ -61,7 +61,7 @@ watch(
if (newGroup) {
formData.name = newGroup.name;
formData.description = newGroup.description;
formData.is_default = newGroup.is_default;
formData.is_default = newGroup.is_default || false;
}
},
{ immediate: true, deep: true }
@@ -73,7 +73,7 @@ const handleSave = async () => {
try {
await formRef.value.validate();
isSaving.value = true;
await updateGroup(groupStore.selectedGroupId, {
await updateGroup(groupStore.selectedGroupId.toString(), {
name: formData.name,
description: formData.description,
is_default: formData.is_default,

View File

@@ -1,13 +1,13 @@
<template>
<div class="group-list" v-loading="groupStore.isLoading">
<el-menu
:default-active="groupStore.selectedGroupId || undefined"
:default-active="groupStore.selectedGroupId?.toString() || undefined"
@select="handleSelect"
>
<el-menu-item
v-for="group in groupStore.groups"
:key="group.id"
:index="group.id"
:index="group.id.toString()"
>
<template #title>
<span>{{ group.name }}</span>
@@ -41,7 +41,7 @@ onMounted(() => {
});
const handleSelect = (index: string) => {
groupStore.selectGroup(index);
groupStore.selectGroup(Number(index));
};
</script>

View File

@@ -1,219 +0,0 @@
<template>
<div class="key-table">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>密钥管理</span>
<el-button type="primary" @click="handleAddKey" :disabled="!groupStore.selectedGroupId">添加密钥</el-button>
</div>
</template>
<el-table :data="keyStore.keys" v-loading="keyStore.isLoading" style="width: 100%">
<el-table-column prop="api_key" label="API Key (部分)" min-width="180">
<template #default="scope">
{{ scope.row.api_key.substring(0, 3) }}...{{ scope.row.api_key.slice(-4) }}
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" width="100" />
<el-table-column prop="model_types" label="可用模型" min-width="150">
<template #default="scope">
<el-tag v-for="model in scope.row.model_types" :key="model" style="margin-right: 5px;">{{ model }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="rate_limit" label="速率限制" width="120">
<template #default="scope">
{{ scope.row.rate_limit }} / {{ scope.row.rate_limit_unit }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80">
<template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'danger'">
{{ scope.row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEditKey(scope.row)">编辑</el-button>
<el-popconfirm
title="确定要删除这个密钥吗?"
@confirm="handleDeleteKey(scope.row.id)"
>
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!keyStore.isLoading && keyStore.keys.length === 0" description="该分组下暂无密钥"></el-empty>
</el-card>
<!-- Add/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
<el-form :model="keyFormData" label-width="120px" ref="keyFormRef" :rules="keyFormRules">
<el-form-item label="API Key" prop="api_key">
<el-input v-model="keyFormData.api_key" placeholder="请输入完整的API Key"></el-input>
</el-form-item>
<el-form-item label="平台" prop="platform">
<el-select v-model="keyFormData.platform" placeholder="请选择平台">
<el-option label="OpenAI" value="OpenAI"></el-option>
<el-option label="Gemini" value="Gemini"></el-option>
</el-select>
</el-form-item>
<el-form-item label="可用模型" prop="model_types">
<el-select
v-model="keyFormData.model_types"
multiple
filterable
allow-create
default-first-option
placeholder="请输入或选择可用模型">
</el-select>
</el-form-item>
<el-form-item label="速率限制" prop="rate_limit">
<el-input-number v-model="keyFormData.rate_limit" :min="0"></el-input-number>
</el-form-item>
<el-form-item label="限制单位" prop="rate_limit_unit">
<el-select v-model="keyFormData.rate_limit_unit">
<el-option label="分钟" value="minute"></el-option>
<el-option label="小时" value="hour"></el-option>
<el-option label="天" value="day"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态" prop="is_active">
<el-switch v-model="keyFormData.is_active"></el-switch>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmSave" :loading="isSaving">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { useKeyStore } from '@/stores/keyStore';
import { useGroupStore } from '@/stores/groupStore';
import * as keyApi from '@/api/keys';
import type { Key } from '@/types/models';
import { ElCard, ElTable, ElTableColumn, ElButton, ElTag, ElPopconfirm, ElDialog, ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElSwitch, ElMessage, ElEmpty, ElInputNumber } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
const keyStore = useKeyStore();
const groupStore = useGroupStore();
const dialogVisible = ref(false);
const isSaving = ref(false);
const isEdit = ref(false);
const currentKeyId = ref<string | null>(null);
const keyFormRef = ref<FormInstance>();
const dialogTitle = computed(() => (isEdit.value ? '编辑密钥' : '添加密钥'));
const initialFormData: Omit<Key, 'id' | 'group_id' | 'usage' | 'created_at' | 'updated_at'> = {
api_key: '',
platform: 'OpenAI',
model_types: [],
rate_limit: 60,
rate_limit_unit: 'minute',
is_active: true,
};
const keyFormData = reactive({ ...initialFormData });
const keyFormRules = reactive<FormRules>({
api_key: [{ required: true, message: '请输入API Key', trigger: 'blur' }],
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
model_types: [{ required: true, message: '请至少输入一个可用模型', trigger: 'change' }],
});
const resetForm = () => {
Object.assign(keyFormData, initialFormData);
currentKeyId.value = null;
};
const handleAddKey = () => {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
};
const handleEditKey = (key: Key) => {
isEdit.value = true;
resetForm();
currentKeyId.value = key.id;
// 只填充表单所需字段
keyFormData.api_key = key.api_key;
keyFormData.platform = key.platform;
keyFormData.model_types = key.model_types;
keyFormData.rate_limit = key.rate_limit;
keyFormData.rate_limit_unit = key.rate_limit_unit;
keyFormData.is_active = key.is_active;
dialogVisible.value = true;
};
const handleDeleteKey = async (id: string) => {
try {
await keyApi.deleteKey(id);
ElMessage.success('删除成功');
if (groupStore.selectedGroupId) {
keyStore.fetchKeys(groupStore.selectedGroupId);
}
} catch (error) {
console.error('Failed to delete key:', error);
ElMessage.error('删除失败');
}
};
const handleConfirmSave = async () => {
if (!keyFormRef.value || !groupStore.selectedGroupId) return;
try {
await keyFormRef.value.validate();
isSaving.value = true;
const dataToSave = {
api_key: keyFormData.api_key,
platform: keyFormData.platform,
model_types: keyFormData.model_types,
rate_limit: keyFormData.rate_limit,
rate_limit_unit: keyFormData.rate_limit_unit,
is_active: keyFormData.is_active,
};
if (isEdit.value && currentKeyId.value) {
await keyApi.updateKey(currentKeyId.value, dataToSave);
} else {
await keyApi.createKey(groupStore.selectedGroupId, dataToSave);
}
ElMessage.success('保存成功');
dialogVisible.value = false;
await keyStore.fetchKeys(groupStore.selectedGroupId);
} catch (error) {
console.error('Failed to save key:', error);
ElMessage.error('保存失败,请检查表单或查看控制台');
} finally {
isSaving.value = false;
}
};
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.el-table {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">快捷操作</h3>
<div class="flex space-x-4">
<button
@click="onAddKey"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
添加密钥
</button>
<button
@click="onCreateGroup"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusCircleIcon class="-ml-1 mr-2 h-5 w-5 text-gray-400" aria-hidden="true" />
创建分组
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/24/solid';
const router = useRouter();
const onAddKey = () => {
// Assuming you have a route for adding a key, possibly on the keys page with a modal
router.push({ name: 'keys', query: { action: 'add' } });
};
const onCreateGroup = () => {
// Assuming you have a route for groups where a creation modal can be triggered
router.push({ name: 'groups', query: { action: 'create' } });
};
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
分组请求统计
</h3>
<div ref="chartRef" style="width: 100%; height: 400px"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import * as echarts from "echarts";
import { useDashboardStore } from "@/stores/dashboardStore";
const chartRef = ref<HTMLElement | null>(null);
const dashboardStore = useDashboardStore();
const { chartData } = storeToRefs(dashboardStore);
let chartInstance: echarts.ECharts | null = null;
const initChart = () => {
if (chartRef.value) {
chartInstance = echarts.init(chartRef.value);
setChartOptions();
}
};
const setChartOptions = () => {
if (!chartInstance) return;
const options: echarts.EChartsOption = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
xAxis: {
type: "category",
data: chartData.value.labels,
axisLabel: {
rotate: 45,
interval: 0,
},
},
yAxis: {
type: "value",
name: "请求数",
},
series: [
{
name: "请求数",
data: chartData.value.data,
type: "bar",
itemStyle: {
color: "#409EFF",
},
},
],
grid: {
left: "3%",
right: "4%",
bottom: "15%",
containLabel: true,
},
};
chartInstance.setOption(options);
};
const resizeChart = () => {
chartInstance?.resize();
};
onMounted(() => {
initChart();
window.addEventListener("resize", resizeChart);
});
onUnmounted(() => {
chartInstance?.dispose();
window.removeEventListener("resize", resizeChart);
});
watch(
chartData,
() => {
setChartOptions();
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="stat in statsData"
:key="stat.name"
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<component
:is="stat.icon"
class="h-8 w-8 text-gray-500"
aria-hidden="true"
/>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt
class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate"
>
{{ stat.name }}
</dt>
<dd class="flex items-baseline">
<span
class="text-2xl font-semibold text-gray-900 dark:text-white"
>
{{ stat.value }}
</span>
</dd>
</dl>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "@/stores/dashboardStore";
import { formatNumber } from "@/types/models";
const dashboardStore = useDashboardStore();
const { stats } = storeToRefs(dashboardStore);
const statsData = computed(() => [
{
name: "总密钥数",
value: formatNumber(stats.value.total_keys || 0),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/KeyIcon")
),
},
{
name: "有效密钥数",
value: formatNumber(stats.value.active_keys || 0),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/CheckCircleIcon")
),
},
{
name: "总请求数",
value: formatNumber(stats.value.total_requests),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/ArrowTrendingUpIcon")
),
},
{
name: "成功率",
value: `${(stats.value.success_rate * 100).toFixed(1)}%`,
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/ChartBarIcon")
),
},
]);
</script>

View File

@@ -0,0 +1,486 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑分组' : '创建分组'"
width="600px"
:before-close="handleClose"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入分组名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分组描述">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入分组描述(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="渠道类型" prop="channel_type">
<el-radio-group v-model="formData.channel_type">
<el-radio value="openai">
<div class="channel-option">
<span class="channel-name">OpenAI</span>
<span class="channel-desc">支持 GPT-3.5GPT-4 等模型</span>
</div>
</el-radio>
<el-radio value="gemini">
<div class="channel-option">
<span class="channel-name">Gemini</span>
<span class="channel-desc">Google Gemini 模型</span>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">配置设置</el-divider>
<el-form-item label="上游地址" prop="config.upstream_url">
<el-input
v-model="formData.config.upstream_url"
placeholder="请输入API上游地址"
/>
<div class="form-tip">
例如https://api.openai.com 或
https://generativelanguage.googleapis.com
</div>
</el-form-item>
<el-form-item label="超时时间">
<el-input-number
v-model="formData.config.timeout"
:min="1000"
:max="300000"
:step="1000"
placeholder="请输入超时时间"
/>
<span class="input-suffix">毫秒</span>
<div class="form-tip">请求超时时间范围1 - 5分钟默认30秒</div>
</el-form-item>
<el-form-item label="最大令牌数">
<el-input-number
v-model="formData.config.max_tokens"
:min="1"
:max="32000"
placeholder="请输入最大令牌数"
/>
<div class="form-tip">单次请求最大令牌数留空使用模型默认值</div>
</el-form-item>
<!-- 高级配置 -->
<el-collapse v-model="activeCollapse">
<el-collapse-item title="高级配置" name="advanced">
<el-form-item label="请求头">
<div class="config-editor">
<el-input
v-model="headersText"
type="textarea"
:rows="4"
placeholder="请输入自定义请求头配置JSON格式"
@blur="validateHeaders"
/>
<div class="form-tip">
格式{"Authorization": "Bearer token", "Custom-Header":
"value"}
</div>
</div>
</el-form-item>
<el-form-item label="其他配置">
<div class="config-editor">
<el-input
v-model="otherConfigText"
type="textarea"
:rows="4"
placeholder="请输入其他配置项JSON格式"
@blur="validateOtherConfig"
/>
<div class="form-tip">其他自定义配置参数将合并到分组配置中</div>
</div>
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? "保存修改" : "创建分组" }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from "vue";
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElRadioGroup,
ElRadio,
ElButton,
ElDivider,
ElCollapse,
ElCollapseItem,
ElMessage,
type FormInstance,
type FormRules,
} from "element-plus";
import type { Group, GroupConfig } from "@/types/models";
interface Props {
visible: boolean;
groupData?: Group | null;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
groupData: null,
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", data: any): void;
}>();
const formRef = ref<FormInstance>();
const dialogVisible = ref(props.visible);
const submitting = ref(false);
const activeCollapse = ref<string[]>([]);
const headersText = ref("");
const otherConfigText = ref("");
// 计算属性
const isEdit = computed(() => !!props.groupData);
// 表单数据
const formData = reactive<{
name: string;
description: string;
channel_type: "openai" | "gemini";
config: GroupConfig;
}>({
name: "",
description: "",
channel_type: "openai",
config: {
upstream_url: "",
timeout: 30000,
max_tokens: undefined,
},
});
// 表单验证规则
const formRules: FormRules = {
name: [
{ required: true, message: "请输入分组名称", trigger: "blur" },
{ min: 2, max: 50, message: "分组名称长度为2-50个字符", trigger: "blur" },
],
channel_type: [
{ required: true, message: "请选择渠道类型", trigger: "change" },
],
"config.upstream_url": [
{ required: true, message: "请输入上游地址", trigger: "blur" },
{
pattern: /^https?:\/\/.+/,
message: "请输入有效的HTTP/HTTPS地址",
trigger: "blur",
},
],
};
// 监听器
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
resetForm();
if (props.groupData) {
loadGroupData();
} else {
setDefaultConfig();
}
}
}
);
watch(dialogVisible, (val) => {
emit("update:visible", val);
});
watch(
() => formData.channel_type,
(newType) => {
setDefaultConfig(newType);
}
);
// 方法
const resetForm = () => {
formData.name = "";
formData.description = "";
formData.channel_type = "openai";
formData.config = {
upstream_url: "",
timeout: 30000,
max_tokens: undefined,
};
headersText.value = "";
otherConfigText.value = "";
activeCollapse.value = [];
submitting.value = false;
nextTick(() => {
formRef.value?.clearValidate();
});
};
const setDefaultConfig = (channelType?: "openai" | "gemini") => {
const type = channelType || formData.channel_type;
if (!isEdit.value) {
switch (type) {
case "openai":
formData.config.upstream_url = "https://api.openai.com";
break;
case "gemini":
formData.config.upstream_url =
"https://generativelanguage.googleapis.com";
break;
}
}
};
const loadGroupData = () => {
if (props.groupData) {
formData.name = props.groupData.name;
formData.description = props.groupData.description;
formData.channel_type = props.groupData.channel_type;
formData.config = { ...props.groupData.config };
// 解析高级配置
if (props.groupData.config.headers) {
headersText.value = JSON.stringify(
props.groupData.config.headers,
null,
2
);
}
// 提取其他配置(排除已知字段)
const { upstream_url, timeout, max_tokens, headers, ...otherConfig } =
props.groupData.config;
if (Object.keys(otherConfig).length > 0) {
otherConfigText.value = JSON.stringify(otherConfig, null, 2);
}
}
};
const validateHeaders = () => {
if (!headersText.value.trim()) return;
try {
JSON.parse(headersText.value);
} catch {
ElMessage.error("请求头配置格式错误请检查JSON语法");
return false;
}
return true;
};
const validateOtherConfig = () => {
if (!otherConfigText.value.trim()) return;
try {
JSON.parse(otherConfigText.value);
} catch {
ElMessage.error("其他配置格式错误请检查JSON语法");
return false;
}
return true;
};
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false;
try {
await formRef.value.validate();
// 验证高级配置
if (headersText.value.trim() && !validateHeaders()) {
return false;
}
if (otherConfigText.value.trim() && !validateOtherConfig()) {
return false;
}
return true;
} catch {
return false;
}
};
const buildConfigData = () => {
const config: GroupConfig = {
upstream_url: formData.config.upstream_url,
timeout: formData.config.timeout,
};
if (formData.config.max_tokens) {
config.max_tokens = formData.config.max_tokens;
}
// 添加自定义请求头
if (headersText.value.trim()) {
try {
config.headers = JSON.parse(headersText.value);
} catch {
// 已在验证中处理
}
}
// 添加其他配置
if (otherConfigText.value.trim()) {
try {
const otherConfig = JSON.parse(otherConfigText.value);
Object.assign(config, otherConfig);
} catch {
// 已在验证中处理
}
}
return config;
};
const handleSubmit = async () => {
if (!(await validateForm())) return;
submitting.value = true;
try {
const saveData = {
name: formData.name,
description: formData.description,
channel_type: formData.channel_type,
config: buildConfigData(),
};
if (isEdit.value) {
emit("save", {
...saveData,
id: props.groupData!.id,
});
} else {
emit("save", saveData);
}
ElMessage.success(isEdit.value ? "分组更新成功" : "分组创建成功");
handleClose();
} catch (error) {
console.error("Save group failed:", error);
ElMessage.error("操作失败,请重试");
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
handleClose();
};
const handleClose = () => {
if (submitting.value) {
ElMessage.warning("操作进行中,请稍后");
return;
}
dialogVisible.value = false;
};
const handleClosed = () => {
resetForm();
};
</script>
<style scoped>
.channel-option {
display: flex;
flex-direction: column;
margin-left: 8px;
}
.channel-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.channel-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.input-suffix {
margin-left: 8px;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.config-editor {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.el-radio) {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
margin-right: 30px;
}
:deep(.el-radio__input) {
margin-top: 2px;
}
:deep(.el-collapse-item__header) {
font-weight: 500;
}
:deep(.el-input-number) {
width: 200px;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="group-list-container">
<div class="header">
<search-input v-model="searchQuery" placeholder="搜索分组..." />
<el-button type="primary" :icon="Plus" @click="handleAddGroup"
>添加分组</el-button
>
</div>
<el-scrollbar class="group-list-scrollbar">
<loading-spinner v-if="groupStore.isLoading" />
<empty-state
v-else-if="filteredGroups.length === 0"
message="未找到分组"
/>
<ul v-else class="group-list">
<li
v-for="group in filteredGroups"
:key="group.id"
:class="{ active: group.id === selectedGroupId }"
@click="handleSelectGroup(group.id)"
>
<div class="group-item">
<span class="group-name">{{ group.name }}</span>
<div class="group-meta">
<el-tag
size="small"
:type="getChannelTypeColor(group.channel_type)"
>
{{ getChannelTypeName(group.channel_type) }}
</el-tag>
<span class="key-count"
>{{ (group.api_keys || []).length }} 密钥</span
>
</div>
</div>
<div class="group-actions">
<el-button size="small" text @click.stop="handleEditGroup(group)">
编辑
</el-button>
<el-button
size="small"
text
type="danger"
@click.stop="handleDeleteGroup(group)"
>
删除
</el-button>
</div>
</li>
</ul>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import SearchInput from "@/components/common/SearchInput.vue";
import LoadingSpinner from "@/components/common/LoadingSpinner.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import { ElButton, ElScrollbar, ElTag, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import type { Group } from "@/types/models";
interface Props {
selectedGroupId?: number;
}
const props = defineProps<Props>();
const selectedGroupId = computed(() => props.selectedGroupId);
const emit = defineEmits<{
(e: "select-group", groupId: number): void;
(e: "add-group"): void;
(e: "edit-group", group: Group): void;
(e: "delete-group", groupId: number): void;
}>();
const groupStore = useGroupStore();
const searchQuery = ref("");
const filteredGroups = computed(() => {
if (!searchQuery.value) {
return groupStore.groups;
}
return groupStore.groups.filter(
(group) =>
group.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
group.description.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getChannelTypeColor = (channelType: string) => {
switch (channelType) {
case "openai":
return "success";
case "gemini":
return "primary";
default:
return "info";
}
};
const getChannelTypeName = (channelType: string) => {
switch (channelType) {
case "openai":
return "OpenAI";
case "gemini":
return "Gemini";
default:
return channelType;
}
};
const handleSelectGroup = (groupId: number) => {
emit("select-group", groupId);
};
const handleAddGroup = () => {
emit("add-group");
};
const handleEditGroup = (group: Group) => {
emit("edit-group", group);
};
const handleDeleteGroup = async (group: Group) => {
try {
await ElMessageBox.confirm(
`确定要删除分组 "${group.name}" 吗?这将同时删除该分组下的所有密钥。`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("delete-group", group.id);
} catch {
// 用户取消删除
}
};
onMounted(() => {
if (groupStore.groups.length === 0) {
groupStore.fetchGroups();
}
});
</script>
<style scoped>
.group-list-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.header {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.group-list-scrollbar {
flex-grow: 1;
}
.group-list {
list-style: none;
padding: 0;
margin: 0;
}
.group-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
margin-bottom: 8px;
border: 1px solid transparent;
}
.group-list li:hover {
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
.group-list li.active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.group-item {
flex: 1;
min-width: 0;
}
.group-name {
font-weight: 500;
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.group-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.key-count {
color: var(--el-text-color-secondary);
}
.group-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.group-list li:hover .group-actions {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="group-stats-container">
<empty-state
v-if="!selectedGroup"
message="请从左侧选择一个分组以查看详情"
/>
<div v-else class="stats-content">
<div class="header">
<h2 class="group-name">{{ selectedGroup.name }}</h2>
<div class="actions">
<el-button :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<p class="group-description">{{ selectedGroup.description || '暂无描述' }}</p>
<el-row :gutter="20" class="stats-cards">
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ keyStore.keys.length }}</div>
<div class="stat-label">密钥总数</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ activeKeysCount }}</div>
<div class="stat-label">已启用</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ disabledKeysCount }}</div>
<div class="stat-label">已禁用</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useGroupStore } from '@/stores/groupStore';
import { useKeyStore } from '@/stores/keyStore';
import EmptyState from '@/components/common/EmptyState.vue';
import { ElButton, ElRow, ElCol, ElCard, ElMessage } from 'element-plus';
import { Edit, Delete } from '@element-plus/icons-vue';
const groupStore = useGroupStore();
const keyStore = useKeyStore();
const selectedGroup = computed(() => groupStore.selectedGroupDetails);
const activeKeysCount = computed(() => {
return keyStore.keys.filter(key => key.status === 'active').length;
});
const disabledKeysCount = computed(() => {
return keyStore.keys.filter(key => key.status !== 'active').length;
});
const handleEdit = () => {
// TODO: Implement edit group logic (e.g., open a dialog)
console.log('Edit group:', selectedGroup.value?.id);
ElMessage.info('编辑功能待实现');
};
const handleDelete = () => {
// TODO: Implement delete group logic (with confirmation)
console.log('Delete group:', selectedGroup.value?.id);
ElMessage.warning('删除功能待实现');
};
</script>
<style scoped>
.group-stats-container {
width: 100%;
}
.stats-content {
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.group-name {
font-size: 24px;
font-weight: bold;
margin: 0;
}
.group-description {
color: #606266;
margin-bottom: 20px;
min-height: 22px;
}
.stats-cards .stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: var(--el-color-primary);
}
.stat-label {
font-size: 14px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="key-batch-ops-container">
<div class="batch-actions">
<el-button @click="handleBatchEnable" :disabled="!hasSelection">
批量启用
</el-button>
<el-button
type="warning"
@click="handleBatchDisable"
:disabled="!hasSelection"
>
批量禁用
</el-button>
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="!hasSelection"
>
批量删除
</el-button>
</div>
<el-button type="primary" :icon="Plus" @click="handleAddNew">
添加密钥
</el-button>
<key-form v-model:visible="isFormVisible" :key-data="null" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useKeyStore } from "@/stores/keyStore";
import KeyForm from "./KeyForm.vue";
import { ElButton, ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
const keyStore = useKeyStore();
const isFormVisible = ref(false);
const hasSelection = computed(() => keyStore.selectedKeyIds.length > 0);
const handleAddNew = () => {
isFormVisible.value = true;
};
const createBatchHandler = (action: "启用" | "禁用" | "删除") => {
const actionMap: {
[key: string]: { status?: "active" | "inactive"; verb: string };
} = {
启用: { status: "active", verb: "启用" },
禁用: { status: "inactive", verb: "禁用" },
删除: { verb: "删除" },
};
return async () => {
const selectedIds = keyStore.selectedKeyIds;
if (selectedIds.length === 0) {
ElMessage.warning("请至少选择一个密钥");
return;
}
try {
await ElMessageBox.confirm(
`确定要${actionMap[action].verb}选中的 ${selectedIds.length} 个密钥吗?`,
"警告",
{
confirmButtonText: `确定${actionMap[action].verb}`,
cancelButtonText: "取消",
type: "warning",
}
);
if (action === "删除") {
await keyStore.batchDelete(selectedIds);
} else {
await keyStore.batchUpdateStatus(
selectedIds,
actionMap[action].status!
);
}
ElMessage.success(`选中的密钥已${actionMap[action].verb}`);
} catch (error) {
if (error !== "cancel") {
ElMessage.error(`批量${actionMap[action].verb}操作失败`);
} else {
ElMessage.info("操作已取消");
}
}
};
};
const handleBatchEnable = createBatchHandler("启用");
const handleBatchDisable = createBatchHandler("禁用");
const handleBatchDelete = createBatchHandler("删除");
</script>
<style scoped>
.key-batch-ops-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.batch-actions {
display: flex;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,368 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑密钥' : '添加密钥'"
width="600px"
:before-close="handleClose"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="API密钥" prop="key_value">
<el-input
v-model="formData.key_value"
type="textarea"
:rows="3"
placeholder="请输入完整的API密钥"
:disabled="isEdit"
/>
<div class="form-tip">
<span v-if="isEdit">编辑时无法修改密钥值</span>
<span v-else>请输入完整的API密钥支持粘贴多行文本</span>
</div>
</el-form-item>
<el-form-item label="密钥状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
placeholder="可选:为此密钥添加备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
<!-- 批量导入模式 -->
<el-collapse v-if="!isEdit" v-model="activeCollapse">
<el-collapse-item title="批量导入密钥" name="batch">
<div class="batch-import-section">
<el-alert
title="批量导入说明"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>每行一个密钥系统会自动分割并创建多个密钥记录</p>
<p>支持以下格式</p>
<ul class="format-list">
<li> sk-xxxxxxxxxxxxxxxxxxxx</li>
<li> sk-proj-xxxxxxxxxxxxxxxxxxxx</li>
<li> 其他格式的API密钥</li>
</ul>
</template>
</el-alert>
<el-form-item label="批量密钥" style="margin-top: 16px">
<el-input
v-model="batchKeys"
type="textarea"
:rows="8"
placeholder="请粘贴多个密钥,每行一个"
@input="handleBatchKeysChange"
/>
<div class="batch-info" v-if="parsedBatchKeys.length > 0">
检测到 {{ parsedBatchKeys.length }} 个密钥
</div>
</el-form-item>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ submitButtonText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from "vue";
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElRadioGroup,
ElRadio,
ElButton,
ElCollapse,
ElCollapseItem,
ElAlert,
ElMessage,
type FormInstance,
type FormRules,
} from "element-plus";
import type { APIKey } from "@/types/models";
interface Props {
visible: boolean;
keyData?: APIKey | null;
groupId?: number;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
keyData: null,
groupId: undefined,
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", data: any): void;
}>();
const formRef = ref<FormInstance>();
const dialogVisible = ref(props.visible);
const submitting = ref(false);
const activeCollapse = ref<string[]>([]);
const batchKeys = ref("");
// 计算属性
const isEdit = computed(() => !!props.keyData);
const submitButtonText = computed(() => {
if (submitting.value) {
return isEdit.value ? "保存中..." : "创建中...";
}
if (parsedBatchKeys.value.length > 1) {
return `批量创建 ${parsedBatchKeys.value.length} 个密钥`;
}
return isEdit.value ? "保存" : "创建密钥";
});
// 表单数据
const formData = reactive<{
key_value: string;
status: "active" | "inactive";
remark: string;
}>({
key_value: "",
status: "active",
remark: "",
});
// 表单验证规则
const formRules: FormRules = {
key_value: [
{ required: true, message: "请输入API密钥", trigger: "blur" },
{ min: 10, message: "密钥长度至少10位", trigger: "blur" },
],
status: [{ required: true, message: "请选择密钥状态", trigger: "change" }],
};
// 批量密钥解析
const parsedBatchKeys = computed(() => {
if (!batchKeys.value.trim()) {
return formData.key_value ? [formData.key_value] : [];
}
return batchKeys.value
.split("\n")
.map((key) => key.trim())
.filter((key) => key.length > 0)
.filter((key, index, arr) => arr.indexOf(key) === index); // 去重
});
// 监听器
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
resetForm();
if (props.keyData) {
loadKeyData();
}
}
}
);
watch(dialogVisible, (val) => {
emit("update:visible", val);
});
// 方法
const resetForm = () => {
formData.key_value = "";
formData.status = "active";
formData.remark = "";
batchKeys.value = "";
activeCollapse.value = [];
submitting.value = false;
nextTick(() => {
formRef.value?.clearValidate();
});
};
const loadKeyData = () => {
if (props.keyData) {
formData.key_value = props.keyData.key_value;
formData.status =
props.keyData.status === "error" ? "inactive" : props.keyData.status;
formData.remark = (props.keyData as any).remark || "";
}
};
const handleBatchKeysChange = () => {
// 如果有批量密钥输入,清空单个密钥输入
if (batchKeys.value.trim()) {
formData.key_value = "";
}
};
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false;
try {
await formRef.value.validate();
// 检查是否有密钥数据
if (parsedBatchKeys.value.length === 0) {
ElMessage.error("请输入至少一个密钥");
return false;
}
// 验证密钥格式
const invalidKeys = parsedBatchKeys.value.filter((key) => key.length < 10);
if (invalidKeys.length > 0) {
ElMessage.error(
`检测到 ${invalidKeys.length} 个无效密钥密钥长度至少10位`
);
return false;
}
return true;
} catch {
return false;
}
};
const handleSubmit = async () => {
if (!(await validateForm())) return;
submitting.value = true;
try {
const saveData = {
status: formData.status,
remark: formData.remark,
group_id: props.groupId,
};
if (isEdit.value) {
// 编辑模式
emit("save", {
...saveData,
id: props.keyData!.id,
key_value: formData.key_value,
});
} else if (parsedBatchKeys.value.length === 1) {
// 单个密钥创建
emit("save", {
...saveData,
key_value: parsedBatchKeys.value[0],
});
} else {
// 批量创建
emit("save", {
...saveData,
keys: parsedBatchKeys.value,
batch: true,
});
}
ElMessage.success(
isEdit.value
? "密钥更新成功"
: `成功创建 ${parsedBatchKeys.value.length} 个密钥`
);
handleClose();
} catch (error) {
console.error("Save key failed:", error);
ElMessage.error("操作失败,请重试");
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
handleClose();
};
const handleClose = () => {
if (submitting.value) {
ElMessage.warning("操作进行中,请稍后");
return;
}
dialogVisible.value = false;
};
const handleClosed = () => {
resetForm();
};
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.batch-import-section {
margin-top: 16px;
}
.format-list {
margin: 8px 0;
padding-left: 16px;
}
.format-list li {
margin: 4px 0;
color: var(--el-text-color-regular);
font-family: monospace;
}
.batch-info {
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-color-success-light-9);
border: 1px solid var(--el-color-success-light-7);
border-radius: 4px;
color: var(--el-color-success);
font-size: 12px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.el-collapse-item__header) {
font-weight: 500;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div class="key-table-container">
<!-- 工具栏 -->
<div class="table-toolbar mb-4">
<div class="flex justify-between items-center">
<div class="flex space-x-2">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加密钥
</el-button>
<el-button @click="handleBatchImport"> 批量导入 </el-button>
<el-dropdown
@command="handleBatchOperation"
v-if="selectedKeys.length > 0"
>
<el-button>
批量操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="enable">批量启用</el-dropdown-item>
<el-dropdown-item command="disable">批量禁用</el-dropdown-item>
<el-dropdown-item command="delete" divided
>批量删除</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex space-x-2">
<SearchInput
v-model="searchKeyword"
placeholder="搜索密钥..."
@search="handleSearch"
/>
</div>
</div>
</div>
<!-- 数据表格 -->
<DataTable
:data="filteredKeys"
:columns="tableColumns"
:loading="loading"
selectable
@selection-change="handleSelectionChange"
>
<!-- 密钥值列 - 脱敏显示 -->
<template #key_value="{ row }">
<div class="key-value-cell flex items-center space-x-2">
<span class="font-mono text-sm">
{{ row.showKey ? row.key_value : maskKey(row.key_value) }}
</span>
<el-button size="small" text @click="toggleKeyVisibility(row)">
<el-icon>
<component :is="row.showKey ? 'Hide' : 'View'" />
</el-icon>
</el-button>
<el-button
size="small"
text
@click="copyKey(row.key_value)"
title="复制密钥"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</template>
<!-- 状态列 -->
<template #status="{ row }">
<StatusBadge :status="row.status" />
</template>
<!-- 使用统计列 -->
<template #usage="{ row }">
<el-tooltip placement="top">
<div class="text-center">
<div class="text-sm font-medium">
{{ formatNumber(row.request_count) }}
</div>
<div class="text-xs text-gray-500" v-if="row.failure_count > 0">
失败: {{ formatNumber(row.failure_count) }}
</div>
</div>
<template #content>
<div>总请求: {{ row.request_count }}</div>
<div>失败次数: {{ row.failure_count }}</div>
<div v-if="row.last_used_at">
最后使用: {{ formatTime(row.last_used_at) }}
</div>
</template>
</el-tooltip>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<div class="flex space-x-1">
<el-button size="small" @click="handleEdit(row)"> 编辑 </el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'warning' : 'success'"
@click="toggleKeyStatus(row)"
>
{{ row.status === "active" ? "禁用" : "启用" }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">
删除
</el-button>
</div>
</template>
</DataTable>
<!-- 密钥表单对话框 -->
<KeyForm
v-model:visible="formVisible"
:key-data="currentKey"
:group-id="currentGroupId"
@save="handleSave"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, withDefaults } from "vue";
import {
ElButton,
ElIcon,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElTooltip,
ElMessage,
ElMessageBox,
} from "element-plus";
import { Plus, ArrowDown, CopyDocument } from "@element-plus/icons-vue";
import DataTable from "@/components/common/DataTable.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import SearchInput from "@/components/common/SearchInput.vue";
import KeyForm from "./KeyForm.vue";
import type { APIKey } from "@/types/models";
import { maskKey, formatNumber } from "@/types/models";
interface Props {
keys: APIKey[];
loading?: boolean;
groupId?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
groupId: undefined,
});
const emit = defineEmits<{
(e: "add"): void;
(e: "edit", key: APIKey): void;
(e: "delete", keyId: number): void;
(e: "toggle-status", key: APIKey): void;
(e: "batch-operation", operation: string, keys: APIKey[]): void;
}>();
const selectedKeys = ref<APIKey[]>([]);
const searchKeyword = ref("");
const formVisible = ref(false);
const currentKey = ref<APIKey | null>(null);
const currentGroupId = ref<number | undefined>(props.groupId);
// 表格列配置
const tableColumns = [
{ prop: "key_value", label: "API密钥", minWidth: 200 },
{ prop: "status", label: "状态", width: 100 },
{ prop: "usage", label: "使用统计", width: 120 },
{ prop: "created_at", label: "创建时间", width: 150 },
];
// 过滤后的密钥列表
const filteredKeys = computed(() => {
let keys = props.keys.map((key) => ({
...key,
showKey: false, // 添加显示状态
}));
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
keys = keys.filter(
(key) =>
key.key_value.toLowerCase().includes(keyword) ||
key.status.toLowerCase().includes(keyword)
);
}
return keys;
});
// 事件处理函数
const handleAdd = () => {
currentKey.value = null;
currentGroupId.value = props.groupId;
formVisible.value = true;
emit("add");
};
const handleEdit = (key: APIKey) => {
currentKey.value = key;
formVisible.value = true;
emit("edit", key);
};
const handleDelete = async (key: APIKey) => {
try {
await ElMessageBox.confirm(
`确定要删除这个密钥吗?此操作不可恢复。`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("delete", key.id);
} catch {
// 用户取消删除
}
};
const toggleKeyStatus = async (key: APIKey) => {
const action = key.status === "active" ? "禁用" : "启用";
try {
await ElMessageBox.confirm(`确定要${action}这个密钥吗?`, `确认${action}`, {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
emit("toggle-status", key);
} catch {
// 用户取消操作
}
};
const handleSelectionChange = (selection: APIKey[]) => {
selectedKeys.value = selection;
};
const handleBatchImport = () => {
// TODO: 实现批量导入功能
ElMessage.info("批量导入功能开发中...");
};
const handleBatchOperation = async (command: string) => {
if (selectedKeys.value.length === 0) {
ElMessage.warning("请先选择要操作的密钥");
return;
}
const operationMap = {
enable: "启用",
disable: "禁用",
delete: "删除",
};
const operation = operationMap[command as keyof typeof operationMap];
try {
await ElMessageBox.confirm(
`确定要${operation}选中的 ${selectedKeys.value.length} 个密钥吗?`,
`确认批量${operation}`,
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("batch-operation", command, selectedKeys.value);
} catch {
// 用户取消操作
}
};
const handleSearch = () => {
// 搜索逻辑已在computed中处理
};
const handleSave = () => {
formVisible.value = false;
// 父组件处理保存逻辑
};
// 工具函数
const toggleKeyVisibility = (key: any) => {
key.showKey = !key.showKey;
};
const copyKey = async (keyValue: string) => {
try {
await navigator.clipboard.writeText(keyValue);
ElMessage.success("密钥已复制到剪贴板");
} catch {
ElMessage.error("复制失败,请手动复制");
}
};
const formatTime = (timeStr: string) => {
return new Date(timeStr).toLocaleString("zh-CN");
};
</script>
<style scoped>
.key-table-container {
width: 100%;
}
.table-toolbar {
padding: 16px 0;
border-bottom: 1px solid var(--el-border-color-light);
}
.key-value-cell {
max-width: 300px;
}
@media (max-width: 768px) {
.table-toolbar .flex {
flex-direction: column;
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="p-6 bg-white shadow-md rounded-lg">
<h3 class="text-lg font-semibold leading-6 text-gray-900 mb-6">分组设置</h3>
<div class="mb-4">
<label for="group-select" class="block text-sm font-medium text-gray-700"
>选择分组</label
>
<select
id="group-select"
v-model="selectedGroup"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<div v-if="selectedGroup">
<p class="text-sm text-gray-600">
<strong>{{ selectedGroupName }}</strong>
分组设置覆盖配置这些配置将优先于系统默认配置
</p>
<!-- Add group-specific setting items here later -->
<div class="mt-4 p-4 border border-dashed rounded-md">
<p class="text-center text-gray-500">分组配置项待实现</p>
</div>
</div>
<div v-else>
<p class="text-center text-gray-500">请先选择一个分组以查看其配置</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { storeToRefs } from "pinia";
const groupStore = useGroupStore();
const { groups } = storeToRefs(groupStore);
const selectedGroup = ref<number | null>(null);
const selectedGroupName = computed(() => {
return groups.value.find((g) => g.id === selectedGroup.value)?.name || "";
});
onMounted(() => {
groupStore.fetchGroups();
});
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="setting-item mb-4">
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
<input
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
:placeholder="placeholder"
/>
<p v-if="description" class="mt-2 text-sm text-gray-500">{{ description }}</p>
<p v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string | number;
label: string;
type?: string;
placeholder?: string;
description?: string;
error?: string;
}>();
defineEmits(['update:modelValue']);
</script>
<style scoped>
.setting-item {
/* Add any specific styling for the setting item here */
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="p-6 bg-white shadow-md rounded-lg">
<h3 class="text-lg font-semibold leading-6 text-gray-900 mb-6">系统设置</h3>
<div v-if="settings" class="space-y-6">
<SettingItem
v-model.number="settings.port"
label="服务端口"
type="number"
description="Web 服务和 API 监听的端口。"
:error="errors['port']"
/>
<SettingItem
v-model="settings.cors.allowed_origins"
label="允许的跨域来源 (CORS)"
description="允许访问 API 的来源列表,用逗号分隔。使用 '*' 表示允许所有来源。"
:error="errors['cors.allowed_origins']"
/>
<SettingItem
v-model.number="settings.timeout.read"
label="读取超时 (秒)"
type="number"
description="服务器读取请求的超时时间。"
:error="errors['timeout.read']"
/>
<SettingItem
v-model.number="settings.timeout.write"
label="写入超时 (秒)"
type="number"
description="服务器写入响应的超时时间。"
:error="errors['timeout.write']"
/>
</div>
<div v-else>
<p>正在加载设置...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useSettingStore } from '@/stores/settingStore';
import SettingItem from './SettingItem.vue';
const settingStore = useSettingStore();
// 我们将在 store 中定义 systemSettings 和 errors
const { systemSettings: settings, errors } = storeToRefs(settingStore);
</script>

View File

@@ -0,0 +1,112 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
width="30%"
:before-close="handleClose"
center
>
<div class="dialog-content">
<el-icon :class="['icon', type]">
<WarningFilled v-if="type === 'warning'" />
<CircleCloseFilled v-if="type === 'delete'" />
<InfoFilled v-if="type === 'info'" />
</el-icon>
<span>{{ content }}</span>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">{{ cancelText }}</el-button>
<el-button :type="confirmButtonType" @click="handleConfirm">
{{ confirmText }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElDialog, ElButton, ElIcon } from 'element-plus';
import { WarningFilled, CircleCloseFilled, InfoFilled } from '@element-plus/icons-vue';
type DialogType = 'warning' | 'delete' | 'info';
const props = withDefaults(defineProps<{
visible: boolean;
title: string;
content: string;
type?: DialogType;
confirmText?: string;
cancelText?: string;
}>(), {
visible: false,
type: 'warning',
confirmText: '确认',
cancelText: '取消',
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm'): void;
(e: 'cancel'): void;
}>();
const dialogVisible = ref(props.visible);
watch(() => props.visible, (val) => {
dialogVisible.value = val;
});
const confirmButtonType = computed(() => {
switch (props.type) {
case 'delete':
return 'danger';
case 'warning':
return 'warning';
default:
return 'primary';
}
});
const handleClose = (done: () => void) => {
emit('update:visible', false);
emit('cancel');
done();
};
const handleConfirm = () => {
emit('confirm');
emit('update:visible', false);
};
const handleCancel = () => {
emit('cancel');
emit('update:visible', false);
};
</script>
<style scoped>
.dialog-content {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
}
.icon {
font-size: 24px;
}
.icon.warning {
color: #E6A23C;
}
.icon.delete {
color: #F56C6C;
}
.icon.info {
color: #909399;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="data-table-container">
<el-table
v-loading="loading"
:data="data"
style="width: 100%"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<el-table-column v-if="selectable" type="selection" width="55" />
<el-table-column
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:sortable="column.sortable ? 'custom' : false"
>
<template #default="{ row }">
<slot :name="column.prop" :row="row">
{{ row[column.prop] }}
</slot>
</template>
</el-table-column>
<el-table-column v-if="$slots.actions" label="操作" fixed="right" width="180">
<template #default="{ row }">
<slot name="actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="pagination"
class="pagination"
:current-page="pagination.currentPage"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults, defineEmits } from 'vue';
import { ElTable, ElTableColumn, ElPagination, ElLoadingDirective as vLoading } from 'element-plus';
export interface TableColumn {
prop: string;
label: string;
width?: string | number;
sortable?: boolean;
}
export interface PaginationConfig {
currentPage: number;
pageSize: number;
total: number;
}
withDefaults(defineProps<{
data: any[];
columns: TableColumn[];
loading?: boolean;
selectable?: boolean;
pagination?: PaginationConfig;
}>(), {
loading: false,
selectable: false,
pagination: undefined,
});
const emit = defineEmits<{
(e: 'selection-change', selection: any[]): void;
(e: 'sort-change', { column, prop, order }: { column: any; prop: string; order: string | null }): void;
(e: 'page-change', page: number): void;
(e: 'size-change', size: number): void;
}>();
const handleSelectionChange = (selection: any[]) => {
emit('selection-change', selection);
};
const handleSortChange = ({ column, prop, order }: { column: any; prop: string; order: string | null }) => {
emit('sort-change', { column, prop, order });
};
const handlePageChange = (page: number) => {
emit('page-change', page);
};
const handleSizeChange = (size: number) => {
emit('size-change', size);
};
</script>
<style scoped>
.data-table-container {
width: 100%;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
:shortcuts="shortcuts"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, withDefaults, defineEmits } from 'vue';
import { ElDatePicker } from 'element-plus';
type DateRangeValue = [Date, Date];
const props = withDefaults(defineProps<{
modelValue: DateRangeValue | null;
}>(), {
modelValue: null,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: DateRangeValue | null): void;
}>();
const dateRange = ref<DateRangeValue | []>(props.modelValue || []);
watch(() => props.modelValue, (val) => {
dateRange.value = val || [];
});
const handleChange = (value: DateRangeValue | null) => {
emit('update:modelValue', value);
};
const shortcuts = [
{
text: '最近一周',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: '最近一个月',
value: () => {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 1);
return [start, end];
},
},
{
text: '最近三个月',
value: () => {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 3);
return [start, end];
},
},
];
</script>
<style scoped>
.el-date-picker {
width: 100%;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="empty-state-container">
<el-empty :description="description">
<template #image>
<slot name="image">
<img v-if="image" :src="image" alt="Empty state" />
</slot>
</template>
<template #default>
<slot name="actions">
<el-button v-if="actionText" type="primary" @click="$emit('action')">
{{ actionText }}
</el-button>
</slot>
</template>
</el-empty>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults, defineEmits } from 'vue';
import { ElEmpty, ElButton } from 'element-plus';
withDefaults(defineProps<{
image?: string;
description?: string;
actionText?: string;
}>(), {
image: '',
description: '暂无数据',
actionText: '',
});
defineEmits<{
(e: 'action'): void;
}>();
</script>
<style scoped>
.empty-state-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
padding: 40px 0;
}
.el-empty__image img {
max-width: 150px;
user-select: none;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div class="loading-spinner" :style="{ width: size, height: size }">
<svg class="spinner" viewBox="0 0 50 50">
<circle
class="path"
cx="25"
cy="25"
r="20"
fill="none"
:stroke="color"
stroke-width="5"
></circle>
</svg>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults } from 'vue';
withDefaults(defineProps<{
size?: string;
color?: string;
}>(), {
size: '48px',
color: '#409EFF', // Element Plus 主色
});
</script>
<style scoped>
.loading-spinner {
display: inline-block;
position: relative;
}
.spinner {
animation: rotate 2s linear infinite;
}
.path {
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<el-autocomplete
v-model="query"
:fetch-suggestions="fetchSuggestions"
placeholder="请输入搜索内容"
clearable
@select="handleSelect"
@input="handleInput"
class="search-input"
>
<template #prepend>
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
</template>
<script setup lang="ts">
import { ref, defineEmits, defineProps, withDefaults } from 'vue';
import { ElAutocomplete, ElIcon } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import { debounce } from 'lodash-es';
interface Suggestion {
value: string;
[key: string]: any;
}
const props = withDefaults(defineProps<{
modelValue: string;
suggestions?: (queryString: string) => Promise<Suggestion[]> | Suggestion[];
debounceTime?: number;
}>(), {
suggestions: () => [],
debounceTime: 300,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'search', value: string): void;
(e: 'select', item: Suggestion): void;
}>();
const query = ref(props.modelValue);
const fetchSuggestions = (queryString: string, cb: (suggestions: Suggestion[]) => void) => {
const results = props.suggestions(queryString);
if (results instanceof Promise) {
results.then(cb);
} else {
cb(results);
}
};
const debouncedSearch = debounce((value: string) => {
emit('search', value);
}, props.debounceTime);
const handleInput = (value: string) => {
query.value = value;
emit('update:modelValue', value);
debouncedSearch(value);
};
const handleSelect = (item: Record<string, any>) => {
const suggestion = item as Suggestion;
emit('select', suggestion);
emit('search', suggestion.value);
};
</script>
<style scoped>
.search-input {
width: 100%;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<el-tag :type="tagType" effect="light" round>
{{ statusText }}
</el-tag>
</template>
<script setup lang="ts">
import { computed, defineProps, withDefaults } from "vue";
import { ElTag } from "element-plus";
type APIKeyStatus = "active" | "inactive" | "error";
const props = withDefaults(
defineProps<{
status: APIKeyStatus;
statusMap?: Record<APIKeyStatus, string>;
}>(),
{
status: "inactive",
statusMap: () => ({
active: "启用",
inactive: "禁用",
error: "错误",
}),
}
);
const tagType = computed(() => {
switch (props.status) {
case "active":
return "success";
case "inactive":
return "warning";
case "error":
return "danger";
default:
return "info";
}
});
const statusText = computed(() => {
return props.statusMap[props.status] || "未知";
});
</script>
<style scoped>
.el-tag {
cursor: default;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<footer class="bg-gray-100 text-center py-4 mt-auto">
<div class="container mx-auto">
<p class="text-sm text-gray-600">
&copy; {{ new Date().getFullYear() }} GPT-Load. All Rights Reserved.
</p>
<p class="text-xs text-gray-500 mt-1">
Version 1.0.0
</p>
</div>
</footer>
</template>
<script setup lang="ts">
// No script needed for this simple component
</script>
<style scoped>
/* Add any component-specific styles here */
</style>

View File

@@ -0,0 +1,260 @@
<template>
<header class="header">
<div class="header-container">
<div class="header-content">
<div class="header-brand">
<router-link to="/" class="brand-link">GPT-Load</router-link>
</div>
<!-- Mobile Menu Button -->
<div class="mobile-menu-button">
<el-button text @click="isMenuOpen = !isMenuOpen">
<el-icon size="20"><Menu /></el-icon>
</el-button>
</div>
<!-- Desktop Menu -->
<nav class="desktop-nav">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-link"
active-class="nav-link-active"
>
{{ item.name }}
</router-link>
</nav>
<!-- User Menu -->
<div class="user-menu">
<div v-if="authStore.isAuthenticated">
<el-dropdown @command="handleUserMenuCommand">
<el-button text>
<span>{{ authStore.user?.username || "User" }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<router-link v-else to="/login" class="login-link">登录</router-link>
</div>
</div>
<!-- Mobile Menu -->
<div v-if="isMenuOpen" class="mobile-menu">
<nav class="mobile-nav">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
@click="isMenuOpen = false"
class="mobile-nav-link"
active-class="mobile-nav-link-active"
>
{{ item.name }}
</router-link>
<hr class="mobile-menu-divider" />
<div v-if="authStore.isAuthenticated" class="mobile-user-section">
<div class="mobile-username">
{{ authStore.user?.username || "User" }}
</div>
<el-button text type="danger" @click="logout" class="mobile-logout">
退出登录
</el-button>
</div>
<router-link
v-else
to="/login"
@click="isMenuOpen = false"
class="mobile-nav-link"
>
登录
</router-link>
</nav>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/authStore";
import { Menu, ArrowDown } from "@element-plus/icons-vue";
const router = useRouter();
const authStore = useAuthStore();
const isMenuOpen = ref(false);
const menuItems = [
{ name: "仪表盘", path: "/dashboard" },
{ name: "分组管理", path: "/groups" },
{ name: "日志", path: "/logs" },
{ name: "系统设置", path: "/settings" },
];
const handleUserMenuCommand = (command: string) => {
if (command === "logout") {
logout();
}
};
const logout = () => {
authStore.logout();
isMenuOpen.value = false;
router.push("/login");
};
</script>
<style scoped>
.header {
background-color: #ffffff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
border-bottom: 1px solid #e5e7eb;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
.header-brand {
display: flex;
align-items: center;
}
.brand-link {
font-size: 20px;
font-weight: 700;
color: #1f2937;
text-decoration: none;
}
.brand-link:hover {
color: #3b82f6;
}
.mobile-menu-button {
display: none;
}
.desktop-nav {
display: flex;
align-items: center;
gap: 24px;
}
.nav-link {
color: #6b7280;
text-decoration: none;
font-weight: 500;
padding: 8px 12px;
border-radius: 6px;
transition: color 0.2s;
}
.nav-link:hover {
color: #3b82f6;
}
.nav-link-active {
color: #3b82f6;
font-weight: 600;
}
.user-menu {
display: flex;
align-items: center;
}
.login-link {
color: #6b7280;
text-decoration: none;
font-weight: 500;
}
.login-link:hover {
color: #3b82f6;
}
.mobile-menu {
display: none;
border-top: 1px solid #e5e7eb;
padding: 16px 0;
}
.mobile-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.mobile-nav-link {
color: #6b7280;
text-decoration: none;
padding: 12px 16px;
border-radius: 6px;
font-weight: 500;
}
.mobile-nav-link:hover {
color: #3b82f6;
background-color: #f3f4f6;
}
.mobile-nav-link-active {
color: #3b82f6;
background-color: #dbeafe;
font-weight: 600;
}
.mobile-menu-divider {
margin: 8px 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.mobile-user-section {
padding: 12px 16px;
}
.mobile-username {
font-weight: 500;
color: #1f2937;
margin-bottom: 8px;
}
.mobile-logout {
width: 100%;
justify-content: flex-start;
}
@media (max-width: 768px) {
.mobile-menu-button {
display: block;
}
.desktop-nav,
.user-menu {
display: none;
}
.mobile-menu {
display: block;
}
}
</style>

View File

@@ -1,179 +1,35 @@
<template>
<el-container style="height: 100vh;">
<el-header class="header">
<div class="header-content">
<div class="header-title">
<h3>GPT Load 管理面板</h3>
</div>
<div class="header-actions" v-if="authStore.isAuthenticated">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
<el-icon><User /></el-icon>
<span style="margin-left: 5px;">管理员</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleSelect"
router
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<template #title>
<span>Dashboard</span>
</template>
</el-menu-item>
<el-menu-item index="/groups">
<el-icon><Files /></el-icon>
<template #title>
<span>Groups</span>
</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>
<span>Logs</span>
</template>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<template #title>
<span>Settings</span>
</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
<div class="main-layout">
<HeaderNav />
<main class="main-content">
<router-view />
</main>
<Footer />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User,
ArrowDown,
SwitchButton,
Odometer,
Files,
Document,
Setting
} from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/authStore'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const activeIndex = ref(route.path)
const handleSelect = (key: string, keyPath: string[]) => {
console.log(key, keyPath)
}
const handleCommand = async (command: string) => {
if (command === 'logout') {
try {
await ElMessageBox.confirm(
'确定要退出登录吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
authStore.logout()
ElMessage.success('已退出登录')
await router.push('/login')
} catch {
// 用户取消退出
}
}
}
<script setup lang="ts">
import HeaderNav from "./HeaderNav.vue";
import Footer from "./Footer.vue";
</script>
<style scoped>
.header {
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
line-height: 60px;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
}
.header-content {
.main-layout {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 20px;
flex-direction: column;
min-height: 100vh;
background-color: var(--el-bg-color-page);
}
.header-title h3 {
margin: 0;
color: #303133;
font-weight: 500;
.main-content {
flex: 1;
padding: 20px;
overflow: auto;
}
.header-actions {
display: flex;
align-items: center;
@media (max-width: 768px) {
.main-content {
padding: 10px;
}
}
.el-dropdown-link {
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
padding: 0 12px;
height: 40px;
border-radius: 4px;
transition: background-color 0.3s;
}
.el-dropdown-link:hover {
background-color: #f5f7fa;
color: #409eff;
}
.el-menu-vertical-demo {
height: calc(100vh - 61px);
border-right: 1px solid #e4e7ed;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
}
:deep(.el-menu-item.is-active) {
background-color: #ecf5ff;
color: #409eff;
}
:deep(.el-menu-item:hover) {
background-color: #f5f7fa;
}
</style>
</style>

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { useAuthStore } from './stores/authStore'

View File

@@ -62,11 +62,10 @@ router.beforeEach((to, _from, next) => {
);
if (requiresAuth && !isAuthenticated) {
// 需要认证但未登录,重定向到登录页
next({
name: "Login",
query: { redirect: to.fullPath },
});
// 临时测试:自动登录
console.log("Auto-login for development testing");
authStore.login("test-key");
next();
} else if (to.name === "Login" && isAuthenticated) {
// 已登录用户访问登录页,重定向到仪表盘
next({ name: "Dashboard" });

View File

@@ -1,11 +1,13 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types/models';
const AUTH_KEY_STORAGE = 'gpt-load-auth-key';
export const useAuthStore = defineStore('auth', () => {
// State
const authKey = ref<string>('');
const user = ref<User | null>(null);
// Computed
const isAuthenticated = computed(() => !!authKey.value);
@@ -13,11 +15,15 @@ export const useAuthStore = defineStore('auth', () => {
// Actions
function login(key: string) {
authKey.value = key;
// For now, we'll just mock a user object.
// In a real app, you'd fetch this from an API.
user.value = { id: '1', username: 'admin' };
localStorage.setItem(AUTH_KEY_STORAGE, key);
}
function logout() {
authKey.value = '';
user.value = null;
localStorage.removeItem(AUTH_KEY_STORAGE);
}
@@ -29,6 +35,9 @@ export const useAuthStore = defineStore('auth', () => {
const storedKey = localStorage.getItem(AUTH_KEY_STORAGE);
if (storedKey) {
authKey.value = storedKey;
// If auth key exists, we can assume the user is logged in.
// You might want to verify the key with the server here.
user.value = { id: '1', username: 'admin' };
}
}
@@ -38,6 +47,7 @@ export const useAuthStore = defineStore('auth', () => {
return {
// State
authKey,
user,
// Computed
isAuthenticated,
// Actions

View File

@@ -1,27 +1,70 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getDashboardStats } from '@/api/dashboard';
import type { DashboardStats } from '@/types/models';
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { getDashboardData } from "@/api/dashboard";
import type { DashboardStats } from "@/types/models";
export const useDashboardStore = defineStore('dashboard', () => {
const stats = ref<DashboardStats | null>(null);
export const useDashboardStore = defineStore("dashboard", () => {
const stats = ref<DashboardStats>({
total_requests: 0,
success_requests: 0,
success_rate: 0,
group_stats: [],
// 前端扩展字段
total_keys: 0,
active_keys: 0,
inactive_keys: 0,
error_keys: 0,
});
const loading = ref(false);
const filters = ref({
timeRange: "7d",
groupId: null as number | null,
});
const fetchStats = async () => {
let pollingInterval: number | undefined;
const chartData = computed(() => {
// 基于group_stats生成图表数据
return {
labels: stats.value.group_stats.map((g) => g.group_name),
data: stats.value.group_stats.map((g) => g.request_count),
};
});
const fetchDashboardData = async () => {
loading.value = true;
try {
const response = await getDashboardStats();
const response = await getDashboardData(
filters.value.timeRange,
filters.value.groupId
);
stats.value = response;
} catch (error) {
console.error('Failed to fetch dashboard stats:', error);
console.error("Failed to fetch dashboard data:", error);
} finally {
loading.value = false;
}
};
const startPolling = () => {
fetchDashboardData();
pollingInterval = window.setInterval(fetchDashboardData, 30000); // 30 seconds
};
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = undefined;
}
};
return {
stats,
loading,
fetchStats,
filters,
chartData,
fetchDashboardData,
startPolling,
stopPolling,
};
});
});

View File

@@ -1,12 +1,13 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import * as groupApi from '@/api/groups';
import type { Group } from '@/types/models';
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import * as groupApi from "@/api/groups";
import type { Group } from "@/types/models";
import { useKeyStore } from "./keyStore";
export const useGroupStore = defineStore('group', () => {
export const useGroupStore = defineStore("group", () => {
// State
const groups = ref<Group[]>([]);
const selectedGroupId = ref<string | null>(null);
const selectedGroupId = ref<number | null>(null);
const isLoading = ref(false);
// Getters
@@ -14,7 +15,7 @@ export const useGroupStore = defineStore('group', () => {
if (!selectedGroupId.value) {
return null;
}
return groups.value.find(g => g.id === selectedGroupId.value) || null;
return groups.value.find((g) => g.id === selectedGroupId.value) || null;
});
// Actions
@@ -22,20 +23,81 @@ export const useGroupStore = defineStore('group', () => {
isLoading.value = true;
try {
groups.value = await groupApi.fetchGroups();
// 默认选中第一个分组
if (groups.value.length > 0 && !selectedGroupId.value) {
selectedGroupId.value = groups.value[0].id;
// 如果没有选中的分组,或者选中的分组已不存在,则默认选中第一个
const selectedExists = groups.value.some(
(g) => g.id === selectedGroupId.value
);
if (groups.value.length > 0 && !selectedExists) {
selectGroup(groups.value[0].id);
}
} catch (error) {
console.error('Failed to fetch groups:', error);
// 这里可以添加更复杂的错误处理逻辑,例如用户通知
console.error("Failed to fetch groups:", error);
} finally {
isLoading.value = false;
}
}
function selectGroup(id: string | null) {
function selectGroup(id: number | null) {
selectedGroupId.value = id;
const keyStore = useKeyStore();
if (id) {
keyStore.fetchKeys(id.toString()); // 暂时转换为string以兼容现有API
} else {
keyStore.clearKeys();
}
}
async function fetchGroupKeys(groupId: number) {
// TODO: 实现获取特定分组的密钥
console.log("fetchGroupKeys not implemented yet, groupId:", groupId);
/*
const group = groups.value.find(g => g.id === groupId);
if (group) {
try {
const keys = await groupApi.fetchGroupKeys(groupId);
group.api_keys = keys;
} catch (error) {
console.error('Failed to fetch group keys:', error);
throw error;
}
}
*/
}
async function createGroup(
groupData: Omit<Group, "id" | "created_at" | "updated_at" | "api_keys">
) {
try {
const newGroup = await groupApi.createGroup(groupData);
await fetchGroups(); // Re-fetch to get the full list
selectGroup(newGroup.id);
} catch (error) {
console.error("Failed to create group:", error);
throw error;
}
}
async function updateGroup(id: number, groupData: Partial<Group>) {
try {
await groupApi.updateGroup(id.toString(), groupData); // 暂时转换为string
await fetchGroups(); // Re-fetch to update the list
} catch (error) {
console.error("Failed to update group:", error);
throw error;
}
}
async function deleteGroup(id: number) {
try {
await groupApi.deleteGroup(id.toString()); // 暂时转换为string
await fetchGroups(); // Re-fetch to update the list
if (selectedGroupId.value === id) {
selectedGroupId.value = null;
}
} catch (error) {
console.error("Failed to delete group:", error);
throw error;
}
}
return {
@@ -48,5 +110,9 @@ export const useGroupStore = defineStore('group', () => {
// Actions
fetchGroups,
selectGroup,
fetchGroupKeys,
createGroup,
updateGroup,
deleteGroup,
};
});
});

View File

@@ -1,14 +1,14 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import * as keyApi from '@/api/keys';
import type { Key } from '@/types/models';
import { useGroupStore } from './groupStore';
import { defineStore } from "pinia";
import { ref } from "vue";
import * as keyApi from "@/api/keys";
import type { APIKey } from "@/types/models";
import { useGroupStore } from "./groupStore";
export const useKeyStore = defineStore('key', () => {
export const useKeyStore = defineStore("key", () => {
// State
const keys = ref<Key[]>([]);
const keys = ref<APIKey[]>([]);
const selectedKeyIds = ref<number[]>([]);
const isLoading = ref(false);
const groupStore = useGroupStore();
// Actions
async function fetchKeys(groupId: string) {
@@ -21,26 +21,149 @@ export const useKeyStore = defineStore('key', () => {
keys.value = await keyApi.fetchKeysInGroup(groupId);
} catch (error) {
console.error(`Failed to fetch keys for group ${groupId}:`, error);
keys.value = []; // 出错时清空列表
keys.value = [];
} finally {
isLoading.value = false;
}
}
// Watch for changes in the selected group and fetch keys accordingly
watch(() => groupStore.selectedGroupId, (newGroupId) => {
if (newGroupId) {
fetchKeys(newGroupId);
} else {
keys.value = [];
function setSelectedKeys(ids: number[]) {
selectedKeyIds.value = ids;
}
function clearKeys() {
keys.value = [];
selectedKeyIds.value = [];
}
async function createKey(
groupId: string,
keyData: Omit<
APIKey,
| "id"
| "group_id"
| "created_at"
| "updated_at"
| "request_count"
| "failure_count"
>
) {
try {
await keyApi.createKey(groupId, keyData);
await fetchKeys(groupId);
} catch (error) {
console.error("Failed to create key:", error);
throw error;
}
}, { immediate: true }); // immediate: true ensures it runs on initialization
}
async function updateKey(id: string, keyData: Partial<APIKey>) {
const groupStore = useGroupStore();
try {
await keyApi.updateKey(id, keyData);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to update key ${id}:`, error);
throw error;
}
}
async function deleteKey(id: string) {
const groupStore = useGroupStore();
try {
await keyApi.deleteKey(id);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to delete key ${id}:`, error);
throw error;
}
}
// 新增方法:更新密钥状态
async function updateKeyStatus(
id: number,
status: "active" | "inactive" | "error"
) {
try {
await keyApi.updateKey(id.toString(), { status });
const groupStore = useGroupStore();
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to update key status ${id}:`, error);
throw error;
}
}
async function batchUpdateStatus(
ids: number[],
status: "active" | "inactive" | "error"
) {
const groupStore = useGroupStore();
try {
await keyApi.batchUpdateKeys(
ids.map((id) => id.toString()),
{ status }
);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch update key status:", error);
throw error;
}
}
// 新增方法:批量删除
async function batchDelete(ids: number[]) {
const groupStore = useGroupStore();
try {
await keyApi.batchDeleteKeys(ids.map((id) => id.toString()));
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch delete keys:", error);
throw error;
}
}
async function batchDeleteKeys(ids: string[]) {
const groupStore = useGroupStore();
try {
await keyApi.batchDeleteKeys(ids);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch delete keys:", error);
throw error;
}
}
return {
// State
keys,
selectedKeyIds,
isLoading,
// Actions
fetchKeys,
setSelectedKeys,
clearKeys,
createKey,
updateKey,
deleteKey,
updateKeyStatus,
batchUpdateStatus,
batchDelete,
batchDeleteKeys,
};
});
});

View File

@@ -1,47 +1,67 @@
import { defineStore } from 'pinia';
import { getSettings, updateSettings as apiUpdateSettings } from '@/api/settings';
import type { Setting } from '@/types/models';
import { getSystemSettings, updateSystemSettings } from '@/api/settings';
import type { SystemSettings } from '@/types/models';
import { ElMessage } from 'element-plus';
interface SettingState {
settings: Setting[];
interface SettingsState {
systemSettings: SystemSettings | null;
loading: boolean;
error: any;
errors: Record<string, string>; // For field-specific validation errors
}
export const useSettingStore = defineStore('setting', {
state: (): SettingState => ({
settings: [],
state: (): SettingsState => ({
systemSettings: null,
loading: false,
error: null,
errors: {},
}),
actions: {
async fetchSettings() {
async fetchSystemSettings() {
this.loading = true;
this.error = null;
try {
const response = await getSettings();
this.settings = response.data;
const response = await getSystemSettings();
this.systemSettings = response.data;
} catch (error) {
this.error = error;
ElMessage.error('Failed to fetch settings.');
ElMessage.error('Failed to fetch system settings.');
} finally {
this.loading = false;
}
},
async updateSettings(settingsToUpdate: Setting[]) {
async saveSystemSettings() {
if (!this.systemSettings) return;
this.loading = true;
this.error = null;
this.errors = {};
// Basic validation example
if (this.systemSettings.port < 1 || this.systemSettings.port > 65535) {
this.errors['port'] = 'Port must be between 1 and 65535.';
}
if (Object.keys(this.errors).length > 0) {
this.loading = false;
ElMessage.error('Please correct the errors before saving.');
return;
}
try {
await apiUpdateSettings(settingsToUpdate);
await this.fetchSettings(); // Refresh the settings after update
ElMessage.success('Settings updated successfully.');
await updateSystemSettings(this.systemSettings);
await this.fetchSystemSettings(); // Refresh state
ElMessage.success('System settings updated successfully.');
} catch (error) {
this.error = error;
ElMessage.error('Failed to update settings.');
ElMessage.error('Failed to update system settings.');
} finally {
this.loading = false;
}
},
// Action to reset settings to their original state (fetched from server)
async resetSystemSettings() {
await this.fetchSystemSettings();
},
},
});

View File

@@ -1,11 +1,19 @@
/* 全局样式重置和基础设置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: "Inter", "SF Pro Display", system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
/* 使用亮色主题 */
color-scheme: light;
color: #1f2937;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -13,67 +21,116 @@
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
html,
body {
height: 100%;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
width: 100%;
height: 100vh;
overflow-x: hidden;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
/* 链接样式 */
a {
font-weight: 500;
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #1d4ed8;
}
/* 标题样式 */
h1,
h2,
h3,
h4,
h5,
h6 {
color: #1f2937;
font-weight: 600;
line-height: 1.2;
margin: 0;
}
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.5rem;
}
/* 按钮基础样式重置 */
button {
border: none;
background: none;
padding: 0;
cursor: pointer;
font-family: inherit;
}
/* 表单元素样式 */
input,
textarea,
select {
font-family: inherit;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 工具类 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,64 +1,175 @@
// Based on internal/models/types.go
export interface Key {
id: string;
group_id: string;
api_key: string;
platform: 'OpenAI' | 'Gemini';
model_types: string[];
rate_limit: number;
rate_limit_unit: 'minute' | 'hour' | 'day';
usage: number;
is_active: boolean;
created_at: string;
updated_at: string;
// Corresponds to the APIKey struct in Go - 修正版本
export interface APIKey {
id: number; // uint -> number
group_id: number; // uint -> number
key_value: string; // 对应后端key_value字段
status: "active" | "inactive" | "error"; // 对应后端status字段
request_count: number; // int64 -> number
failure_count: number; // int64 -> number
last_used_at?: string; // *time.Time -> optional string
created_at: string; // time.Time -> string
updated_at: string; // time.Time -> string
}
// 为了兼容保留Key别名
export type Key = APIKey;
// Corresponds to the Group struct in Go - 修正版本
export interface Group {
id: string;
name: string;
description: string;
is_default: boolean;
created_at: string;
updated_at: string;
id: number; // uint -> number
name: string;
description: string;
channel_type: "openai" | "gemini"; // 明确的渠道类型
is_default?: boolean; // 添加默认分组标识
config: GroupConfig; // 解析后的配置对象
api_keys?: APIKey[]; // 关联的API密钥可选
created_at: string;
updated_at: string;
}
export interface GroupWithKeys extends Group {
keys: Key[];
// 分组配置结构
export interface GroupConfig {
upstream_url: string;
timeout?: number;
max_tokens?: number;
[key: string]: any;
}
// 分组请求统计
export interface GroupRequestStat {
group_name: string;
request_count: number;
}
// 仪表盘统计数据 - 根据后端DashboardStats修正
export interface DashboardStats {
total_requests: number;
success_requests: number;
success_rate: number;
group_stats: GroupRequestStat[];
total_requests: number; // 对应后端total_requests
success_requests: number; // 对应后端success_requests
success_rate: number; // 对应后端success_rate
group_stats: GroupRequestStat[]; // 对应后端group_stats
// 前端扩展字段
total_keys?: number;
active_keys?: number;
inactive_keys?: number;
error_keys?: number;
}
// 请求日志
export interface RequestLog {
id: string;
timestamp: string;
group_id: number;
key_id: number;
group_id: number; // uint -> number
key_id: number; // uint -> number
source_ip: string;
status_code: number;
request_path: string;
request_body_snippet: string;
}
export interface Setting {
key: string;
value: string;
// Corresponds to the SystemSetting struct in Go
export interface SystemSetting {
id: number;
setting_key: string;
setting_value: string;
description: string;
created_at: string;
updated_at: string;
}
export interface AuthUser {
key: string;
isAuthenticated: boolean;
}
export interface User {
id: string;
username: string;
}
// Represents a simplified setting for frontend forms
export interface Setting {
key: string;
value: string;
}
}
// Corresponds to the structured system settings
export interface CorsSettings {
allowed_origins: string;
}
export interface TimeoutSettings {
read: number;
write: number;
}
export interface SystemSettings {
port: number;
cors: CorsSettings;
timeout: TimeoutSettings;
}
// A generic type for different setting categories
export type SettingCategory =
| "system"
| "auth"
| "performance"
| "logs"
| "group";
// 数据转换适配器 - 兼容旧数据格式
export const adaptLegacyKey = (legacyKey: any): APIKey => ({
id: Number(legacyKey.id),
group_id: Number(legacyKey.group_id),
key_value: legacyKey.api_key || legacyKey.key_value,
status: legacyKey.is_active ? "active" : "inactive",
request_count: legacyKey.usage || legacyKey.request_count || 0,
failure_count: legacyKey.failure_count || 0,
last_used_at: legacyKey.last_used_at,
created_at: legacyKey.created_at,
updated_at: legacyKey.updated_at,
});
export const adaptLegacyGroup = (legacyGroup: any): Group => ({
id: Number(legacyGroup.id),
name: legacyGroup.name,
description: legacyGroup.description,
channel_type: legacyGroup.channel_type || "openai",
config:
typeof legacyGroup.config === "string"
? JSON.parse(legacyGroup.config || "{}")
: legacyGroup.config || {},
api_keys: legacyGroup.api_keys?.map(adaptLegacyKey),
created_at: legacyGroup.created_at,
updated_at: legacyGroup.updated_at,
});
// 工具函数
export const maskKey = (key: string): string => {
if (!key || key.length < 8) return "****";
return key.substring(0, 4) + "****" + key.substring(key.length - 4);
};
export const getStatusColor = (status: string): string => {
switch (status) {
case "active":
return "success";
case "inactive":
return "warning";
case "error":
return "danger";
default:
return "info";
}
};
export const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + "K";
}
return num.toString();
};

View File

@@ -1,53 +1,163 @@
<template>
<div class="dashboard-page">
<h1>仪表盘</h1>
<div v-if="loading">加载中...</div>
<div v-if="stats" class="stats-grid">
<el-card>
<el-statistic title="总请求数" :value="stats.total_requests" />
</el-card>
<el-card>
<el-statistic title="成功请求数" :value="stats.success_requests" />
</el-card>
<el-card>
<el-statistic title="成功率" :value="stats.success_rate" :formatter="rateFormatter" />
</el-card>
<div class="dashboard-container">
<div class="dashboard-header">
<h1>仪表盘</h1>
<p>查看您账户的总体使用情况和统计数据</p>
</div>
<LoadingSpinner v-if="loading && !stats.total_keys" />
<div v-else class="dashboard-content">
<!-- 统计卡片 -->
<StatsCards />
<!-- 快捷操作和筛选 -->
<div class="dashboard-grid">
<div class="quick-actions-section">
<QuickActions />
</div>
<div class="filters-section">
<el-card shadow="never">
<template #header>
<h3>筛选图表</h3>
</template>
<div class="filter-controls">
<!-- 时间范围筛选 -->
<el-select
v-model="filters.timeRange"
@change="onFilterChange"
placeholder="选择时间范围"
style="width: 200px"
>
<el-option label="过去 24 小时" value="24h" />
<el-option label="过去 7 天" value="7d" />
<el-option label="过去 30 天" value="30d" />
</el-select>
<!-- 分组筛选 -->
<el-select
v-model="filters.groupId"
@change="onFilterChange"
placeholder="选择分组"
style="width: 200px"
clearable
>
<el-option
v-for="group in groupStore.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</div>
</el-card>
</div>
</div>
<!-- 请求统计图表 -->
<RequestChart />
</div>
<el-card v-if="stats && stats.group_stats.length > 0" class="chart-card">
<StatsChart :data="stats.group_stats" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useDashboardStore } from '@/stores/dashboardStore';
import StatsChart from '@/components/StatsChart.vue';
import { onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "@/stores/dashboardStore";
import { useGroupStore } from "@/stores/groupStore";
import StatsCards from "@/components/business/dashboard/StatsCards.vue";
import RequestChart from "@/components/business/dashboard/RequestChart.vue";
import QuickActions from "@/components/business/dashboard/QuickActions.vue";
import LoadingSpinner from "@/components/common/LoadingSpinner.vue";
const dashboardStore = useDashboardStore();
const { stats, loading } = storeToRefs(dashboardStore);
const groupStore = useGroupStore();
const { stats, loading, filters } = storeToRefs(dashboardStore);
const onFilterChange = () => {
dashboardStore.fetchDashboardData();
};
onMounted(() => {
dashboardStore.fetchStats();
dashboardStore.startPolling();
groupStore.fetchGroups(); // 获取分组列表用于筛选
});
const rateFormatter = (rate: number) => {
return `${(rate * 100).toFixed(2)}%`;
};
onUnmounted(() => {
dashboardStore.stopPolling();
});
</script>
<style scoped>
.dashboard-page {
padding: 20px;
.dashboard-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.stats-grid {
.dashboard-header {
margin-bottom: 24px;
}
.dashboard-header h1 {
font-size: 28px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.dashboard-header p {
color: #6b7280;
font-size: 14px;
}
.dashboard-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
grid-template-columns: 1fr 2fr;
gap: 24px;
align-items: start;
}
.chart-card {
margin-top: 20px;
.quick-actions-section {
min-height: 200px;
}
</style>
.filters-section h3 {
font-size: 18px;
font-weight: 500;
color: #1f2937;
margin: 0;
}
.filter-controls {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-container {
padding: 16px;
}
.filter-controls {
flex-direction: column;
}
.filter-controls .el-select {
width: 100% !important;
}
}
</style>

View File

@@ -1,26 +1,313 @@
<template>
<div class="groups-view">
<el-row :gutter="20" class="main-layout">
<el-col :span="6" class="left-panel">
<group-list />
</el-col>
<el-col :span="18" class="right-panel">
<div class="config-section">
<group-config-form />
<!-- 左侧分组列表 -->
<el-col :xs="24" :sm="8" :md="6" class="left-panel">
<div class="left-content">
<GroupList
:selected-group-id="selectedGroupId"
@select-group="handleSelectGroup"
@add-group="handleAddGroup"
@edit-group="handleEditGroup"
@delete-group="handleDeleteGroup"
/>
</div>
<div class="keys-section">
<key-table />
</el-col>
<!-- 右侧内容区域 -->
<el-col :xs="24" :sm="16" :md="18" class="right-panel">
<div v-if="selectedGroup" class="right-content">
<!-- 分组信息卡片 -->
<div class="group-info-card">
<div class="card-header">
<div class="group-title">
<h3>{{ selectedGroup.name }}</h3>
<el-tag
:type="getChannelTypeColor(selectedGroup.channel_type)"
size="large"
>
{{ getChannelTypeName(selectedGroup.channel_type) }}
</el-tag>
</div>
<div class="card-actions">
<el-button @click="handleEditGroup(selectedGroup)">
编辑分组
</el-button>
<el-button
type="danger"
@click="handleDeleteGroup(selectedGroup.id)"
>
删除分组
</el-button>
</div>
</div>
<div class="card-content">
<p class="group-description">
{{ selectedGroup.description || "暂无描述" }}
</p>
<div class="group-stats">
<div class="stat-item">
<span class="stat-label">密钥总数</span>
<span class="stat-value">{{ groupKeys.length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">有效密钥</span>
<span class="stat-value">{{ activeKeysCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">总请求数</span>
<span class="stat-value">{{ totalRequests }}</span>
</div>
</div>
<div class="group-config" v-if="selectedGroup.config">
<h4>配置信息</h4>
<div
class="config-item"
v-if="selectedGroup.config.upstream_url"
>
<span class="config-label">上游地址:</span>
<span class="config-value">{{
selectedGroup.config.upstream_url
}}</span>
</div>
<div class="config-item" v-if="selectedGroup.config.timeout">
<span class="config-label">超时时间:</span>
<span class="config-value"
>{{ selectedGroup.config.timeout }}ms</span
>
</div>
</div>
</div>
</div>
<!-- 密钥管理区域 -->
<div class="keys-section">
<div class="section-header">
<h4>密钥管理</h4>
</div>
<KeyTable
:keys="groupKeys"
:loading="loading"
:group-id="selectedGroupId"
@add="handleAddKey"
@edit="handleEditKey"
@delete="handleDeleteKey"
@toggle-status="handleToggleKeyStatus"
@batch-operation="handleBatchOperation"
/>
</div>
</div>
<!-- 未选择分组的提示 -->
<div v-else class="empty-state">
<EmptyState
message="请选择一个分组来查看详情"
description="在左侧选择一个分组,或者创建新的分组"
>
<el-button type="primary" @click="handleAddGroup">
创建新分组
</el-button>
</EmptyState>
</div>
</el-col>
</el-row>
<!-- 分组表单对话框 -->
<GroupForm
v-model:visible="groupFormVisible"
:group-data="currentGroup"
@save="handleSaveGroup"
/>
</div>
</template>
<script setup lang="ts">
import GroupList from '@/components/GroupList.vue';
import GroupConfigForm from '@/components/GroupConfigForm.vue';
import KeyTable from '@/components/KeyTable.vue';
import { ElRow, ElCol } from 'element-plus';
import { ref, computed, onMounted } from "vue";
import { ElRow, ElCol, ElButton, ElTag, ElMessage } from "element-plus";
import GroupList from "@/components/business/groups/GroupList.vue";
import KeyTable from "@/components/business/keys/KeyTable.vue";
import GroupForm from "@/components/business/groups/GroupForm.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import { useGroupStore } from "@/stores/groupStore";
import { useKeyStore } from "@/stores/keyStore";
import type { Group, APIKey } from "@/types/models";
const groupStore = useGroupStore();
const keyStore = useKeyStore();
const selectedGroupId = ref<number | undefined>();
const groupFormVisible = ref(false);
const currentGroup = ref<Group | null>(null);
const loading = ref(false);
// 计算属性
const selectedGroup = computed(() => {
if (!selectedGroupId.value) return null;
return groupStore.groups.find((g) => g.id === selectedGroupId.value);
});
const groupKeys = computed(() => {
if (!selectedGroup.value) return [];
return selectedGroup.value.api_keys || [];
});
const activeKeysCount = computed(() => {
return groupKeys.value.filter((key) => key.status === "active").length;
});
const totalRequests = computed(() => {
return groupKeys.value.reduce((total, key) => total + key.request_count, 0);
});
// 工具函数
const getChannelTypeColor = (channelType: string) => {
switch (channelType) {
case "openai":
return "success";
case "gemini":
return "primary";
default:
return "info";
}
};
const getChannelTypeName = (channelType: string) => {
switch (channelType) {
case "openai":
return "OpenAI";
case "gemini":
return "Gemini";
default:
return channelType;
}
};
// 事件处理函数
const handleSelectGroup = (groupId: number) => {
selectedGroupId.value = groupId;
// 加载分组的密钥数据
loadGroupKeys(groupId);
};
const handleAddGroup = () => {
currentGroup.value = null;
groupFormVisible.value = true;
};
const handleEditGroup = (group: Group) => {
currentGroup.value = group;
groupFormVisible.value = true;
};
const handleDeleteGroup = async (groupId: number) => {
try {
await groupStore.deleteGroup(groupId);
ElMessage.success("分组删除成功");
if (selectedGroupId.value === groupId) {
selectedGroupId.value = undefined;
}
} catch (error) {
ElMessage.error("删除分组失败");
}
};
const handleSaveGroup = async (groupData: any) => {
try {
if (currentGroup.value) {
await groupStore.updateGroup(currentGroup.value.id, groupData);
ElMessage.success("分组更新成功");
} else {
await groupStore.createGroup(groupData);
ElMessage.success("分组创建成功");
}
groupFormVisible.value = false;
} catch (error) {
ElMessage.error("保存分组失败");
}
};
const handleAddKey = () => {
// KeyTable组件会处理添加密钥的逻辑
};
const handleEditKey = (key: APIKey) => {
// KeyTable组件会处理编辑密钥的逻辑
console.log("Edit key:", key.id);
};
const handleDeleteKey = async (keyId: number) => {
try {
await keyStore.deleteKey(keyId.toString());
ElMessage.success("密钥删除成功");
// 重新加载当前分组的密钥
if (selectedGroupId.value) {
loadGroupKeys(selectedGroupId.value);
}
} catch (error) {
ElMessage.error("删除密钥失败");
}
};
const handleToggleKeyStatus = async (key: APIKey) => {
try {
const newStatus = key.status === "active" ? "inactive" : "active";
await keyStore.updateKeyStatus(key.id, newStatus);
ElMessage.success(`密钥已${newStatus === "active" ? "启用" : "禁用"}`);
// 重新加载当前分组的密钥
if (selectedGroupId.value) {
loadGroupKeys(selectedGroupId.value);
}
} catch (error) {
ElMessage.error("操作失败");
}
};
const handleBatchOperation = async (operation: string, keys: APIKey[]) => {
try {
switch (operation) {
case "enable":
await keyStore.batchUpdateStatus(
keys.map((k) => k.id),
"active"
);
ElMessage.success(`批量启用 ${keys.length} 个密钥成功`);
break;
case "disable":
await keyStore.batchUpdateStatus(
keys.map((k) => k.id),
"inactive"
);
ElMessage.success(`批量禁用 ${keys.length} 个密钥成功`);
break;
case "delete":
await keyStore.batchDelete(keys.map((k) => k.id));
ElMessage.success(`批量删除 ${keys.length} 个密钥成功`);
break;
}
// 重新加载当前分组的密钥
if (selectedGroupId.value) {
loadGroupKeys(selectedGroupId.value);
}
} catch (error) {
ElMessage.error("批量操作失败");
}
};
const loadGroupKeys = async (groupId: number) => {
try {
loading.value = true;
await groupStore.fetchGroupKeys(groupId);
} catch (error) {
console.error("加载分组密钥失败:", error);
} finally {
loading.value = false;
}
};
onMounted(() => {
// 加载分组列表
groupStore.fetchGroups();
});
</script>
<style scoped>
@@ -30,24 +317,209 @@ import { ElRow, ElCol } from 'element-plus';
box-sizing: border-box;
}
.main-layout, .left-panel, .right-panel {
.main-layout {
height: 100%;
}
.left-panel {
background-color: #fff;
border-radius: 4px;
overflow-y: auto;
height: 100%;
}
.left-content {
background-color: white;
border-radius: 8px;
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.right-panel {
height: 100%;
}
.right-content {
display: flex;
flex-direction: column;
gap: 20px;
height: 100%;
}
.config-section, .keys-section {
background-color: #fff;
border-radius: 4px;
.group-info-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
</style>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--el-border-color-light);
}
.group-title {
display: flex;
align-items: center;
gap: 12px;
}
.group-title h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.card-actions {
display: flex;
gap: 8px;
}
.card-content {
padding: 20px;
}
.group-description {
margin: 0 0 20px 0;
color: var(--el-text-color-regular);
line-height: 1.5;
}
.group-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
padding: 12px;
background-color: var(--el-bg-color-page);
border-radius: 6px;
}
.stat-label {
display: block;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
display: block;
font-size: 20px;
font-weight: 600;
color: var(--el-color-primary);
}
.group-config {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.group-config h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: 13px;
color: var(--el-text-color-regular);
}
.config-value {
font-size: 13px;
color: var(--el-text-color-primary);
font-family: monospace;
}
.keys-section {
flex: 1;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
min-height: 0;
display: flex;
flex-direction: column;
}
.section-header {
margin-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-light);
padding-bottom: 12px;
}
.section-header h4 {
margin: 0;
font-size: 16px;
color: var(--el-text-color-primary);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.groups-view {
padding: 10px;
}
.main-layout {
flex-direction: column;
}
.left-panel {
height: auto;
margin-bottom: 20px;
}
.left-content {
height: 300px;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.group-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.group-stats {
grid-template-columns: 1fr;
}
.config-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
</style>

View File

@@ -35,7 +35,7 @@
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
native-type="submit"
class="login-button"
>
{{ loading ? '登录中...' : '登录' }}

View File

@@ -1,63 +1,481 @@
<template>
<div class="logs-page">
<h1>日志查询</h1>
<LogFilter />
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" label="时间" width="180" :formatter="formatDate" />
<el-table-column prop="group_id" label="分组ID" width="100" />
<el-table-column prop="key_id" label="密钥ID" width="100" />
<el-table-column prop="source_ip" label="源IP" width="150" />
<el-table-column prop="status_code" label="状态码" width="100" />
<el-table-column prop="request_path" label="请求路径" />
<el-table-column prop="request_body_snippet" label="请求体片段" />
</el-table>
<el-pagination
background
layout="prev, pager, next, sizes"
:total="pagination.total"
:page-size="pagination.size"
:current-page="pagination.page"
@current-change="handlePageChange"
@size-change="handleSizeChange"
class="pagination-container"
/>
<div class="logs-view">
<div class="page-header">
<h1 class="text-2xl font-semibold text-gray-900">请求日志</h1>
<p class="mt-2 text-sm text-gray-600">
查看和管// 筛选器 const filters = reactive({ dateRange: [] as [Date,
Date] | [], groupId: undefined as number | undefined, statusCode:
undefined as number | undefined, keyword: '', });志记录
</p>
</div>
<!-- 筛选器 -->
<div class="filters-card">
<el-card shadow="never">
<div class="filters-grid">
<div class="filter-item">
<label class="filter-label">时间范围</label>
<DateRangePicker v-model="filters.dateRange" />
</div>
<div class="filter-item">
<label class="filter-label">分组</label>
<el-select
v-model="filters.groupId"
placeholder="选择分组"
clearable
@clear="filters.groupId = ''"
>
<el-option
v-for="group in groupStore.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态码</label>
<el-select
v-model="filters.statusCode"
placeholder="选择状态码"
clearable
@clear="filters.statusCode = ''"
>
<el-option label="200 - 成功" :value="200" />
<el-option label="400 - 请求错误" :value="400" />
<el-option label="401 - 未授权" :value="401" />
<el-option label="429 - 限流" :value="429" />
<el-option label="500 - 服务器错误" :value="500" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">搜索</label>
<SearchInput
v-model="filters.keyword"
placeholder="搜索IP地址或请求路径..."
@search="handleSearch"
/>
</div>
<div class="filter-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" @click="applyFilters">应用筛选</el-button>
</div>
</div>
</el-card>
</div>
<!-- 日志表格 -->
<div class="logs-table">
<DataTable
:data="filteredLogs"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
@page-change="handlePageChange"
@size-change="handleSizeChange"
>
<!-- 时间戳列 -->
<template #timestamp="{ row }">
<div class="timestamp-cell">
{{ formatTimestamp(row.timestamp) }}
</div>
</template>
<!-- 状态码列 -->
<template #status_code="{ row }">
<el-tag :type="getStatusType(row.status_code)">
{{ row.status_code }}
</el-tag>
</template>
<!-- 分组列 -->
<template #group="{ row }">
<span class="group-name">
{{ getGroupName(row.group_id) }}
</span>
</template>
<!-- 请求路径列 -->
<template #request_path="{ row }">
<el-tooltip placement="top" :content="row.request_path">
<span class="request-path">
{{ truncateText(row.request_path, 50) }}
</span>
</el-tooltip>
</template>
<!-- IP地址列 -->
<template #source_ip="{ row }">
<span class="ip-address">{{ row.source_ip }}</span>
</template>
<!-- 请求体预览列 -->
<template #request_body="{ row }">
<el-button
size="small"
text
@click="showRequestBody(row)"
v-if="row.request_body_snippet"
>
查看详情
</el-button>
<span v-else class="text-gray-400">无内容</span>
</template>
</DataTable>
</div>
<!-- 请求详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="请求详情" width="800px">
<div v-if="selectedLog" class="request-detail">
<div class="detail-section">
<h4>基本信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="时间">
{{ formatTimestamp(selectedLog.timestamp) }}
</el-descriptions-item>
<el-descriptions-item label="状态码">
<el-tag :type="getStatusType(selectedLog.status_code)">
{{ selectedLog.status_code }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ selectedLog.source_ip }}
</el-descriptions-item>
<el-descriptions-item label="分组">
{{ getGroupName(selectedLog.group_id) }}
</el-descriptions-item>
<el-descriptions-item label="请求路径" :span="2">
<code>{{ selectedLog.request_path }}</code>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section" v-if="selectedLog.request_body_snippet">
<h4>请求内容</h4>
<div class="request-body-container">
<pre class="request-body">{{
selectedLog.request_body_snippet
}}</pre>
</div>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useLogStore } from '@/stores/logStore';
import LogFilter from '@/components/LogFilter.vue';
import type { RequestLog } from '@/types/models';
import { ref, reactive, computed, onMounted } from "vue";
import {
ElCard,
ElSelect,
ElOption,
ElButton,
ElTag,
ElTooltip,
ElDialog,
ElDescriptions,
ElDescriptionsItem,
ElMessage,
} from "element-plus";
import DataTable from "@/components/common/DataTable.vue";
import SearchInput from "@/components/common/SearchInput.vue";
import DateRangePicker from "@/components/common/DateRangePicker.vue";
import { useGroupStore } from "@/stores/groupStore";
import type { RequestLog } from "@/types/models";
const logStore = useLogStore();
const { logs, loading, pagination } = storeToRefs(logStore);
const groupStore = useGroupStore();
onMounted(() => {
logStore.fetchLogs();
const loading = ref(false);
const detailDialogVisible = ref(false);
const selectedLog = ref<RequestLog | null>(null);
// 筛选器
const filters = reactive({
dateRange: null as [Date, Date] | null,
groupId: "" as string | number | "",
statusCode: "" as string | number | "",
keyword: "",
});
// 分页
const pagination = reactive({
currentPage: 1,
pageSize: 20,
total: 0,
});
// 表格列配置
const tableColumns = [
{ prop: "timestamp", label: "时间", width: 180 },
{ prop: "status_code", label: "状态码", width: 100 },
{ prop: "group", label: "分组", width: 120 },
{ prop: "source_ip", label: "IP地址", width: 140 },
{ prop: "request_path", label: "请求路径", minWidth: 200 },
{ prop: "request_body", label: "请求内容", width: 120 },
];
// 模拟日志数据(实际应该从 logStore 获取)
const mockLogs: RequestLog[] = [
{
id: "1",
timestamp: new Date().toISOString(),
group_id: 1,
key_id: 1,
source_ip: "192.168.1.100",
status_code: 200,
request_path: "/v1/chat/completions",
request_body_snippet:
'{"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]}',
},
{
id: "2",
timestamp: new Date(Date.now() - 60000).toISOString(),
group_id: 1,
key_id: 2,
source_ip: "192.168.1.101",
status_code: 429,
request_path: "/v1/chat/completions",
request_body_snippet:
'{"model": "gpt-4", "messages": [{"role": "user", "content": "Hi there"}]}',
},
{
id: "3",
timestamp: new Date(Date.now() - 120000).toISOString(),
group_id: 2,
key_id: 3,
source_ip: "192.168.1.102",
status_code: 401,
request_path: "/v1/models",
request_body_snippet: "",
},
];
// 计算属性
const filteredLogs = computed(() => {
let logs = mockLogs;
// 应用筛选器
if (filters.groupId) {
logs = logs.filter((log) => log.group_id === filters.groupId);
}
if (filters.statusCode) {
logs = logs.filter((log) => log.status_code === filters.statusCode);
}
if (filters.keyword) {
const keyword = filters.keyword.toLowerCase();
logs = logs.filter(
(log) =>
log.source_ip.toLowerCase().includes(keyword) ||
log.request_path.toLowerCase().includes(keyword)
);
}
if (filters.dateRange) {
const [start, end] = filters.dateRange;
logs = logs.filter((log) => {
const logTime = new Date(log.timestamp);
return logTime >= start && logTime <= end;
});
}
// 更新分页总数
pagination.total = logs.length;
// 应用分页
const startIndex = (pagination.currentPage - 1) * pagination.pageSize;
const endIndex = startIndex + pagination.pageSize;
return logs.slice(startIndex, endIndex);
});
// 方法
const formatTimestamp = (timestamp: string) => {
return new Date(timestamp).toLocaleString("zh-CN");
};
const getStatusType = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) return "success";
if (statusCode >= 400 && statusCode < 500) return "warning";
if (statusCode >= 500) return "danger";
return "info";
};
const getGroupName = (groupId: number) => {
const group = groupStore.groups.find((g) => g.id === groupId);
return group?.name || `分组 ${groupId}`;
};
const truncateText = (text: string, maxLength: number) => {
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
};
const showRequestBody = (log: RequestLog) => {
selectedLog.value = log;
detailDialogVisible.value = true;
};
const handleSearch = () => {
pagination.currentPage = 1;
// 搜索逻辑已在 computed 中处理
};
const applyFilters = () => {
pagination.currentPage = 1;
// 筛选逻辑已在 computed 中处理
ElMessage.success("筛选条件已应用");
};
const resetFilters = () => {
filters.dateRange = null;
filters.groupId = "";
filters.statusCode = "";
filters.keyword = "";
pagination.currentPage = 1;
ElMessage.success("筛选条件已重置");
};
const handlePageChange = (page: number) => {
logStore.setPage(page);
pagination.currentPage = page;
};
const handleSizeChange = (size: number) => {
logStore.setSize(size);
pagination.pageSize = size;
pagination.currentPage = 1;
};
const formatDate = (_row: RequestLog, _column: any, cellValue: string) => {
return new Date(cellValue).toLocaleString();
};
onMounted(() => {
// 加载分组数据用于筛选
groupStore.fetchGroups();
// 加载日志数据
// TODO: 实现真实的日志加载逻辑
// logStore.fetchLogs();
});
</script>
<style scoped>
.logs-page {
padding: 20px;
.logs-view {
padding: 24px;
background-color: var(--el-bg-color-page);
min-height: 100vh;
}
.pagination-container {
margin-top: 20px;
.page-header {
margin-bottom: 24px;
}
.filters-card {
margin-bottom: 24px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
align-items: end;
}
.filter-item {
display: flex;
justify-content: flex-end;
flex-direction: column;
gap: 8px;
}
</style>
.filter-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.filter-actions {
display: flex;
gap: 8px;
}
.logs-table {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.timestamp-cell {
font-family: monospace;
font-size: 13px;
}
.group-name {
font-weight: 500;
}
.request-path {
font-family: monospace;
font-size: 12px;
color: var(--el-text-color-regular);
}
.ip-address {
font-family: monospace;
font-size: 13px;
}
.request-detail {
max-height: 60vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section h4 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.request-body-container {
background-color: var(--el-bg-color-page);
border-radius: 6px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
}
.request-body {
margin: 0;
font-family: "Monaco", "Consolas", monospace;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 768px) {
.logs-view {
padding: 16px;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filter-actions {
justify-content: stretch;
}
.filter-actions .el-button {
flex: 1;
}
}
</style>

View File

View File

@@ -0,0 +1,63 @@
<template>
<div class="logs-page">
<h1>日志查询</h1>
<LogFilter />
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" label="时间" width="180" :formatter="formatDate" />
<el-table-column prop="group_id" label="分组ID" width="100" />
<el-table-column prop="key_id" label="密钥ID" width="100" />
<el-table-column prop="source_ip" label="源IP" width="150" />
<el-table-column prop="status_code" label="状态码" width="100" />
<el-table-column prop="request_path" label="请求路径" />
<el-table-column prop="request_body_snippet" label="请求体片段" />
</el-table>
<el-pagination
background
layout="prev, pager, next, sizes"
:total="pagination.total"
:page-size="pagination.size"
:current-page="pagination.page"
@current-change="handlePageChange"
@size-change="handleSizeChange"
class="pagination-container"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useLogStore } from '@/stores/logStore';
import LogFilter from '@/components/LogFilter.vue';
import type { RequestLog } from '@/types/models';
const logStore = useLogStore();
const { logs, loading, pagination } = storeToRefs(logStore);
onMounted(() => {
logStore.fetchLogs();
});
const handlePageChange = (page: number) => {
logStore.setPage(page);
};
const handleSizeChange = (size: number) => {
logStore.setSize(size);
};
const formatDate = (_row: RequestLog, _column: any, cellValue: string) => {
return new Date(cellValue).toLocaleString();
};
</script>
<style scoped>
.logs-page {
padding: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -1,44 +1,113 @@
<template>
<div v-loading="loading">
<h1>Settings</h1>
<el-form :model="form" label-width="200px">
<el-form-item v-for="setting in settings" :key="setting.key" :label="setting.key">
<el-input v-model="form[setting.key]"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSettings">Save</el-button>
</el-form-item>
</el-form>
<div class="flex h-full bg-gray-100">
<!-- Left Navigation -->
<aside class="w-64 bg-white p-4 shadow-md">
<h2 class="text-xl font-bold mb-6">设置</h2>
<nav class="space-y-2">
<a
v-for="item in navigation"
:key="item.name"
@click="activeTab = item.component"
:class="[
'block px-4 py-2 rounded-md cursor-pointer',
activeTab === item.component
? 'bg-indigo-500 text-white'
: 'text-gray-700 hover:bg-gray-200',
]"
>
{{ item.name }}
</a>
</nav>
</aside>
<!-- Right Content -->
<main class="flex-1 p-8 overflow-y-auto">
<div class="max-w-4xl mx-auto">
<transition name="fade" mode="out-in">
<component :is="activeComponent" />
</transition>
<!-- Action Buttons -->
<div class="mt-8 flex justify-end space-x-4">
<button
@click="handleReset"
class="px-6 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
重置
</button>
<button
@click="handleSave"
:disabled="loading"
class="px-6 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{{ loading ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { computed, onMounted, shallowRef } from 'vue';
import { useSettingStore } from '@/stores/settingStore';
import { storeToRefs } from 'pinia';
import type { Setting } from '@/types/models';
import SystemSettings from '@/components/business/settings/SystemSettings.vue';
import GroupSettings from '@/components/business/settings/GroupSettings.vue';
const settingStore = useSettingStore();
const { settings, loading } = storeToRefs(settingStore);
const { loading } = storeToRefs(settingStore);
const form = ref<Record<string, string>>({});
const navigation = [
{ name: '系统设置', component: 'SystemSettings' },
{ name: '认证设置', component: 'AuthSettings' },
{ name: '性能设置', component: 'PerformanceSettings' },
{ name: '日志设置', component: 'LogSettings' },
{ name: '分组设置', component: 'GroupSettings' },
];
const components: Record<string, any> = {
SystemSettings,
GroupSettings,
// Placeholder for other setting components
AuthSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">认证设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
PerformanceSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">性能设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
LogSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">日志设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
};
const activeTab = shallowRef('SystemSettings');
const activeComponent = computed(() => components[activeTab.value]);
onMounted(() => {
settingStore.fetchSettings();
// Fetch initial data for the default tab
settingStore.fetchSystemSettings();
});
watch(settings, (newSettings) => {
form.value = newSettings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string>);
}, { immediate: true, deep: true });
const saveSettings = () => {
const settingsToUpdate: Setting[] = Object.entries(form.value).map(([key, value]) => ({
key,
value,
}));
settingStore.updateSettings(settingsToUpdate);
const handleSave = () => {
// This logic would need to be more sophisticated if handling multiple setting types
if (activeTab.value === 'SystemSettings') {
settingStore.saveSystemSettings();
}
// Add logic for other setting types here
};
</script>
const handleReset = () => {
if (activeTab.value === 'SystemSettings') {
settingStore.resetSystemSettings();
}
// Add logic for other setting types here
};
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>