test: web
This commit is contained in:
@@ -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
288
web/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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()}`);
|
||||
};
|
@@ -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);
|
||||
};
|
||||
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -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);
|
||||
}
|
@@ -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,
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
38
web/src/components/business/dashboard/QuickActions.vue
Normal file
38
web/src/components/business/dashboard/QuickActions.vue
Normal 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>
|
92
web/src/components/business/dashboard/RequestChart.vue
Normal file
92
web/src/components/business/dashboard/RequestChart.vue
Normal 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>
|
76
web/src/components/business/dashboard/StatsCards.vue
Normal file
76
web/src/components/business/dashboard/StatsCards.vue
Normal 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>
|
486
web/src/components/business/groups/GroupForm.vue
Normal file
486
web/src/components/business/groups/GroupForm.vue
Normal 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.5、GPT-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>
|
233
web/src/components/business/groups/GroupList.vue
Normal file
233
web/src/components/business/groups/GroupList.vue
Normal 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>
|
123
web/src/components/business/groups/GroupStats.vue
Normal file
123
web/src/components/business/groups/GroupStats.vue
Normal 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>
|
108
web/src/components/business/keys/KeyBatchOps.vue
Normal file
108
web/src/components/business/keys/KeyBatchOps.vue
Normal 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>
|
368
web/src/components/business/keys/KeyForm.vue
Normal file
368
web/src/components/business/keys/KeyForm.vue
Normal 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>
|
328
web/src/components/business/keys/KeyTable.vue
Normal file
328
web/src/components/business/keys/KeyTable.vue
Normal 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>
|
0
web/src/components/business/keys/KeyTableNew.vue
Normal file
0
web/src/components/business/keys/KeyTableNew.vue
Normal file
53
web/src/components/business/settings/GroupSettings.vue
Normal file
53
web/src/components/business/settings/GroupSettings.vue
Normal 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>
|
33
web/src/components/business/settings/SettingItem.vue
Normal file
33
web/src/components/business/settings/SettingItem.vue
Normal 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>
|
47
web/src/components/business/settings/SystemSettings.vue
Normal file
47
web/src/components/business/settings/SystemSettings.vue
Normal 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>
|
112
web/src/components/common/ConfirmDialog.vue
Normal file
112
web/src/components/common/ConfirmDialog.vue
Normal 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>
|
108
web/src/components/common/DataTable.vue
Normal file
108
web/src/components/common/DataTable.vue
Normal 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>
|
74
web/src/components/common/DateRangePicker.vue
Normal file
74
web/src/components/common/DateRangePicker.vue
Normal 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>
|
52
web/src/components/common/EmptyState.vue
Normal file
52
web/src/components/common/EmptyState.vue
Normal 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>
|
64
web/src/components/common/LoadingSpinner.vue
Normal file
64
web/src/components/common/LoadingSpinner.vue
Normal 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>
|
75
web/src/components/common/SearchInput.vue
Normal file
75
web/src/components/common/SearchInput.vue
Normal 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>
|
50
web/src/components/common/StatusBadge.vue
Normal file
50
web/src/components/common/StatusBadge.vue
Normal 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>
|
20
web/src/layouts/Footer.vue
Normal file
20
web/src/layouts/Footer.vue
Normal 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">
|
||||
© {{ 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>
|
260
web/src/layouts/HeaderNav.vue
Normal file
260
web/src/layouts/HeaderNav.vue
Normal 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>
|
@@ -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>
|
||||
|
@@ -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'
|
||||
|
@@ -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" });
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
@@ -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();
|
||||
},
|
||||
},
|
||||
});
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -35,7 +35,7 @@
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
native-type="submit"
|
||||
class="login-button"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
|
@@ -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>
|
||||
|
0
web/src/views/Logs_new.vue
Normal file
0
web/src/views/Logs_new.vue
Normal file
63
web/src/views/Logs_old.vue
Normal file
63
web/src/views/Logs_old.vue
Normal 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>
|
@@ -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>
|
Reference in New Issue
Block a user