feat: 重构前端

This commit is contained in:
tbphp
2025-07-02 17:15:10 +08:00
parent 6a96c4464b
commit f15d0dd8da
102 changed files with 5392 additions and 10344 deletions

25
.editorconfig Normal file
View File

@@ -0,0 +1,25 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,vue}]
indent_size = 2
[*.{md,markdown}]
trim_trailing_whitespace = false
[*.go]
indent_style = tab
indent_size = 4
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab

42
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,42 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] "
labels: "bug"
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. macOS, Windows, Linux]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- Node.js version: [e.g. 18.x]
- Package manager: [e.g. pnpm, npm, yarn]
**Additional context**
Add any other context about the problem here.
**Logs**
Please include any relevant logs:
```
paste logs here
```

View File

@@ -0,0 +1,31 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: "enhancement"
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
**Implementation details (if applicable)**
- Affected components:
- Potential breaking changes:
- Dependencies:
**Would you like to work on this feature?**
- [ ] Yes, I would like to implement this feature
- [ ] I need help with implementation
- [ ] I prefer someone else to implement this

58
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,58 @@
---
name: Pull Request
about: Submit a pull request to contribute to the project
title: ""
labels: ""
assignees: ""
---
## Description
Please include a summary of the change and which issue is fixed. Include relevant motivation and context.
Fixes # (issue)
## Type of change
Please select the relevant options:
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] Performance improvement
- [ ] Code refactoring
- [ ] Test updates
## Testing
Please describe the tests that you ran to verify your changes:
- [ ] Unit tests pass
- [ ] Integration tests pass
- [ ] Manual testing completed
Test Configuration:
- Node.js version:
- Browser(s):
- OS:
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
## Screenshots (if applicable)
Please add screenshots to help reviewers understand the changes.
## Additional Notes
Add any other notes or context about the pull request here.

2
.gitignore vendored
View File

@@ -120,7 +120,6 @@ coverage
.vscode-test .vscode-test
# IDE files # IDE files
.vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
@@ -191,7 +190,6 @@ tramp
.\#* .\#*
# VS Code # VS Code
.vscode/
*.code-workspace *.code-workspace
# JetBrains IDEs # JetBrains IDEs

49
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,49 @@
{
"recommendations": [
// Vue 开发必备
"vue.volar",
"vue.vscode-typescript-vue-plugin",
// 代码格式化和质量
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
// TypeScript 支持
"ms-vscode.vscode-typescript-next",
// Git 增强
"eamodio.gitlens",
"mhutchie.git-graph",
// 开发体验增强
"christian-kohler.path-intellisense",
"visualstudioexptteam.vscodeintellicode",
"ms-vscode.vscode-json",
// 文件图标
"vscode-icons-team.vscode-icons",
// 自动重命名标签
"formulahendry.auto-rename-tag",
// TODO 高亮
"wayou.vscode-todo-highlight",
// 注释增强
"aaron-bond.better-comments",
// 代码片段
"hollowtree.vue-snippets",
// 错误镜头
"usernamehw.errorlens",
// 导入成本
"wix.vscode-import-cost"
],
"unwantedRecommendations": [
"ms-vscode.vscode-typescript",
"hookyqr.beautify",
"coenraads.bracket-pair-colorizer-2"
]
}

87
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,87 @@
{
// 编辑器设置
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"editor.trimAutoWhitespace": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
// 文件关联
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.wordWrap": "on"
},
// ESLint 设置
"eslint.workingDirectories": ["web"],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue"
],
// TypeScript 设置
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
// 搜索排除
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/coverage": true,
"**/*.log": true,
"**/pnpm-lock.yaml": true,
"**/package-lock.json": true,
"**/yarn.lock": true
},
// 终端设置
"terminal.integrated.defaultProfile.osx": "zsh",
// Git 设置
"git.autofetch": true,
"git.confirmSync": false,
"git.enableSmartCommit": true,
// Emmet 设置
"emmet.includeLanguages": {
"vue": "html"
}
}

View File

@@ -43,10 +43,9 @@ build-all: clean ## 为所有支持的平台构建二进制文件
.PHONY: run .PHONY: run
run: ## 构建前端并运行服务器 run: ## 构建前端并运行服务器
@echo "--- Building frontend... ---" @echo "--- Building frontend... ---"
cd web && npm install && npm run build
@echo "--- Preparing backend... ---"
@rm -rf cmd/gpt-load/dist @rm -rf cmd/gpt-load/dist
@cp -r web/dist cmd/gpt-load/dist cd web && pnpm install && pnpm run build
@echo "--- Preparing backend... ---"
@echo "--- Starting backend... ---" @echo "--- Starting backend... ---"
go run $(MAIN_PATH)/main.go go run $(MAIN_PATH)/main.go

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1804
fe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"name": "tb-vite-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.9.0",
"naive-ui": "^2.41.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^22.15.24",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}

1307
fe/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
<script setup lang="ts">
import Layout from '@/components/Layout.vue'
</script>
<template>
<Layout />
</template>

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,5 +0,0 @@
<template>
<n-button quaternary round>
退出
</n-button>
</template>

View File

@@ -1,10 +0,0 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './utils/router'
import naive from 'naive-ui'
createApp(App)
.use(router)
.use(naive)
.mount('#app')

View File

@@ -1,82 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#app {
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
}
.flex {
display: flex;
}
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.items-end {
align-items: flex-end;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-nowrap {
flex-wrap: nowrap;
}
.grow {
flex-grow: 1;
}
.shrink {
flex-shrink: 1;
}

View File

@@ -1,15 +0,0 @@
<template>
<base-info-card />
<line-chart class="chart" />
</template>
<script setup lang="ts">
import BaseInfoCard from '@/components/BaseInfoCard.vue'
import LineChart from '@/components/LineChart.vue'
</script>
<style scoped>
.chart {
margin-top: 20px;
}
</style>

View File

@@ -1,3 +0,0 @@
<template>
<h1>Login</h1>
</template>

View File

@@ -1,3 +0,0 @@
<template>
<div>logs</div>
</template>

View File

@@ -1,3 +0,0 @@
<template>
<div>settings</div>
</template>

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,19 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,31 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// 解析配置
resolve: {
// 配置路径别名
alias: {
'@': path.resolve(__dirname, './src')
}
},
// 开发服务器配置
server: {
// 代理配置示例
proxy: {
'/api': {
target: 'http://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
// 构建配置
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})

10
web/.browserslistrc Normal file
View File

@@ -0,0 +1,10 @@
# Browsers that we support
> 1%
last 2 versions
not dead
not ie 11
not op_mini all
Chrome >= 87
Firefox >= 78
Safari >= 14
Edge >= 88

24
web/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
web/.lintstagedrc Normal file
View File

@@ -0,0 +1,7 @@
{
"*.{js,jsx,ts,tsx,vue}": ["eslint --fix", "prettier --write"],
"*.{json,jsonc}": ["prettier --write"],
"*.{css,scss,less}": ["prettier --write"],
"*.{md,markdown}": ["prettier --write"],
"*.{yaml,yml}": ["prettier --write"]
}

30
web/.npmrc Normal file
View File

@@ -0,0 +1,30 @@
# Use pnpm as package manager
package-manager=pnpm
# Registry configuration
registry=https://registry.npmjs.org/
# Install configuration
prefer-offline=true
prefer-frozen-lockfile=true
strict-peer-dependencies=false
# Hoisting configuration
shamefully-hoist=false
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
# Workspace configuration
link-workspace-packages=true
# Node.js configuration
node-version=18
# Store configuration
store-dir=~/.pnpm-store
# Logging
loglevel=info
# Security
audit-level=moderate

43
web/.prettierignore Normal file
View File

@@ -0,0 +1,43 @@
# Build outputs
dist
dist-ssr
coverage
# Dependencies
node_modules
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment files
.env
.env.*
!.env.example
# IDE files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Lock files
package-lock.json
yarn.lock
pnpm-lock.yaml

48
web/.prettierrc Normal file
View File

@@ -0,0 +1,48 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"endOfLine": "lf",
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore",
"embeddedLanguageFormatting": "auto",
"overrides": [
{
"files": ["*.vue"],
"options": {
"parser": "vue",
"vueIndentScriptAndStyle": false
}
},
{
"files": ["*.json", "*.jsonc"],
"options": {
"parser": "json",
"trailingComma": "none"
}
},
{
"files": ["*.md", "*.markdown"],
"options": {
"parser": "markdown",
"printWidth": 80,
"proseWrap": "preserve",
"tabWidth": 2
}
},
{
"files": ["*.yaml", "*.yml"],
"options": {
"parser": "yaml",
"tabWidth": 2
}
}
]
}

View File

@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

102
web/eslint.config.js Normal file
View File

@@ -0,0 +1,102 @@
import js from '@eslint/js'
import configPrettier from '@vue/eslint-config-prettier'
import configTypeScript from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '**/*.d.ts'],
},
// Base configurations
js.configs.recommended,
...pluginVue.configs['flat/essential'],
...configTypeScript(),
configPrettier,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
// Vue 规则
'vue/multi-word-component-names': 'off', // 允许单词组件名,适应现有代码
'vue/no-unused-vars': 'error',
'vue/no-unused-components': 'warn',
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/component-name-in-template-casing': ['warn', 'kebab-case'],
'vue/prop-name-casing': ['error', 'camelCase'],
'vue/attribute-hyphenation': ['error', 'always'],
'vue/v-on-event-hyphenation': ['error', 'always'],
'vue/html-self-closing': [
'warn',
{
html: {
void: 'never',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/html-indent': ['error', 2],
'vue/script-indent': ['error', 2],
// Vue 3 Composition API 规则
'vue/no-setup-props-destructure': 'error',
'vue/prefer-import-from-vue': 'error',
'vue/no-deprecated-slot-attribute': 'error',
'vue/no-deprecated-slot-scope-attribute': 'error',
// TypeScript 规则
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-unused-expressions': 'error',
// 通用 JavaScript/TypeScript 规则
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'warn',
'prefer-const': 'error',
'no-var': 'error',
'no-unused-vars': 'off', // 使用 TypeScript 版本
eqeqeq: ['error', 'always'],
curly: ['error', 'all'],
'no-throw-literal': 'error',
'prefer-promise-reject-errors': 'error',
// 开源项目最佳实践
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-script-url': 'error',
'no-alert': 'warn',
'no-duplicate-imports': 'error',
'prefer-template': 'error',
'object-shorthand': 'error',
'prefer-arrow-callback': 'error',
'arrow-spacing': 'error',
'no-useless-return': 'error',
},
},
]

View File

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

3446
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,61 @@
{ {
"name": "web", "name": "gpt-load-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"description": "GPT Load Balancer Frontend - A modern Vue 3 frontend for GPT load balancing service",
"type": "module", "type": "module",
"keywords": ["vue3", "typescript", "vite", "naive-ui", "gpt", "load-balancer", "frontend"],
"author": "tbphp",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tbphp/gpt-load.git",
"directory": "web"
},
"bugs": {
"url": "https://github.com/tbphp/gpt-load/issues"
},
"homepage": "https://github.com/tbphp/gpt-load#readme",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "vue-tsc --build --force",
"clean": "rm -rf dist node_modules/.vite",
"test:unit": "echo 'No tests specified' && exit 0",
"prepare": "echo 'Prepare script for potential Git hooks'",
"check-all": "pnpm lint:check && pnpm format:check && pnpm type-check"
}, },
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0",
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.2.0", "axios": "^1.9.0",
"@types/lodash-es": "^4.17.12", "naive-ui": "^2.41.0",
"axios": "^1.10.0", "vue": "^3.5.13",
"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" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.7", "@eslint/js": "^9.30.1",
"@vitejs/plugin-vue": "^6.0.0", "@types/node": "^22.15.24",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.0.0",
"@vue/eslint-config-typescript": "^14.0.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"postcss": "^8.5.6", "eslint": "^9.30.1",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-vue": "^9.0.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.0", "vite": "^6.3.5",
"vue-tsc": "^2.2.10" "vue-tsc": "^2.2.8"
} }
} }

2286
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,17 +0,0 @@
import apiClient from './index';
export interface LoginRequest {
auth_key: string;
}
export interface LoginResponse {
success: boolean;
message: string;
}
export const login = async (authKey: string): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/auth/login', {
auth_key: authKey
});
return response.data;
};

View File

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

View File

@@ -1,92 +0,0 @@
import apiClient from "./index";
import type { Group } from "../types/models";
/**
* 获取所有分组列表
*/
export const fetchGroups = (): Promise<Group[]> => {
return apiClient.get("/groups").then((res) => {
const groups = res.data.data;
// 将后端返回的 config 字符串解析为对象
return groups.map((group: any) => ({
...group,
config:
typeof group.config === "string"
? JSON.parse(group.config)
: group.config,
}));
});
};
/**
* 获取单个分组的详细信息
* @param id 分组ID
*/
export const fetchGroup = (id: string): Promise<Group> => {
return apiClient.get(`/groups/${id}`).then((res) => {
const group = res.data.data;
// 将后端返回的 config 字符串解析为对象
return {
...group,
config:
typeof group.config === "string"
? JSON.parse(group.config)
: group.config,
};
});
};
/**
* 创建一个新的分组
* @param groupData 新分组的数据
*/
export const createGroup = (
groupData: Omit<Group, "id" | "created_at" | "updated_at" | "api_keys">
): Promise<Group> => {
// 将 config 对象转换为 JSON 字符串,匹配后端期望的格式
const requestData = {
...groupData,
config:
typeof groupData.config === "object"
? JSON.stringify(groupData.config)
: groupData.config,
};
console.log("createGroup - Original data:", groupData);
console.log("createGroup - Request data:", requestData);
console.log("createGroup - Config type:", typeof requestData.config);
return apiClient.post("/groups", requestData).then((res) => res.data.data);
};
/**
* 更新一个已存在的分组
* @param id 分组ID
* @param groupData 要更新的数据
*/
export const updateGroup = (
id: string,
groupData: Partial<
Omit<Group, "id" | "created_at" | "updated_at" | "api_keys">
>
): Promise<Group> => {
// 将 config 对象转换为 JSON 字符串,匹配后端期望的格式
const requestData = {
...groupData,
config:
groupData.config && typeof groupData.config === "object"
? JSON.stringify(groupData.config)
: groupData.config,
};
return apiClient
.put(`/groups/${id}`, requestData)
.then((res) => res.data.data);
};
/**
* 删除一个分组
* @param id 分组ID
*/
export const deleteGroup = (id: string): Promise<void> => {
return apiClient.delete(`/groups/${id}`).then((res) => res.data);
};

View File

@@ -1,50 +0,0 @@
import axios from "axios";
import { useAuthStore } from "@/stores/authStore";
import router from "@/router";
const apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
});
// 请求拦截器:自动添加认证头
apiClient.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
const authKey = authStore.getAuthKey();
if (authKey) {
config.headers.Authorization = `Bearer ${authKey}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器处理401认证失败
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
// 认证失败,清除登录状态并跳转到登录页
const authStore = useAuthStore();
authStore.logout();
// 跳转到登录页(如果不在登录页的话)
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -1,71 +0,0 @@
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);
};
/**
* 在指定分组下创建一个新的密钥
* @param groupId 分组ID
* @param keyData 新密钥的数据
*/
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);
};
/**
* 更新一个已存在的密钥
* @param id 密钥ID
* @param keyData 要更新的数据
*/
export const updateKey = (id: string, keyData: Partial<Key>): Promise<Key> => {
return apiClient.put(`/keys/${id}`, keyData).then((res) => res.data.data);
};
/**
* 删除一个密钥
* @param id 密钥ID
*/
export const deleteKey = (id: string): Promise<void> => {
return apiClient.delete(`/keys/${id}`).then((res) => res.data);
};
/**
* 批量更新密钥
* @param ids 密钥ID列表
* @param data 要更新的数据
*/
export const batchUpdateKeys = (
ids: string[],
data: Partial<Key>
): Promise<void> => {
return apiClient
.post("/keys/batch-update", { ids, data })
.then((res) => res.data);
};
/**
* 批量删除密钥
* @param ids 密钥ID列表
*/
export const batchDeleteKeys = (ids: string[]): Promise<void> => {
return apiClient.post("/keys/batch-delete", { ids }).then((res) => res.data);
};

View File

@@ -1,24 +0,0 @@
import request from './index';
import type { RequestLog } from '@/types/models';
export type { RequestLog };
export interface LogQuery {
page?: number;
size?: number;
group_id?: number;
start_time?: string;
end_time?: string;
status_code?: number;
}
export interface PaginatedLogs {
total: number;
page: number;
size: number;
data: RequestLog[];
}
export const getLogs = (query: LogQuery): Promise<PaginatedLogs> => {
return request.get('/logs', { params: query });
};

View File

@@ -1,22 +0,0 @@
import request from './index';
import type { SettingCategory, SystemSettings } from '@/types/models';
// 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}`);
}
// A generic function to update settings for a specific category
export function updateSettings<T>(category: SettingCategory, settings: T) {
return request.put(`/settings/${category}`, settings);
}
// Specific functions for system settings as an example
export function getSystemSettings() {
return getSettings<SystemSettings>('system');
}
export function updateSystemSettings(settings: SystemSettings) {
return updateSettings('system', settings);
}

View File

@@ -1,99 +0,0 @@
<template>
<div class="group-config-form">
<el-card v-if="groupStore.selectedGroupDetails" shadow="never">
<template #header>
<div class="card-header">
<span>分组配置</span>
<el-button type="primary" @click="handleSave" :loading="isSaving"
>保存</el-button
>
</div>
</template>
<el-form :model="formData" label-width="120px" ref="formRef">
<el-form-item
label="分组名称"
prop="name"
:rules="[{ required: true, message: '请输入分组名称' }]"
>
<el-input v-model="formData.name"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea"></el-input>
</el-form-item>
<el-form-item label="设为默认" prop="is_default">
<el-switch v-model="formData.is_default"></el-switch>
</el-form-item>
</el-form>
</el-card>
<el-empty v-else description="请先从左侧选择一个分组"></el-empty>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { updateGroup } from "@/api/groups";
import {
ElCard,
ElForm,
ElFormItem,
ElInput,
ElButton,
ElSwitch,
ElMessage,
ElEmpty,
} from "element-plus";
import type { FormInstance } from "element-plus";
const groupStore = useGroupStore();
const formRef = ref<FormInstance>();
const isSaving = ref(false);
const formData = reactive({
name: "",
description: "",
is_default: false,
});
watch(
() => groupStore.selectedGroupDetails,
(newGroup) => {
if (newGroup) {
formData.name = newGroup.name;
formData.description = newGroup.description;
formData.is_default = newGroup.is_default || false;
}
},
{ immediate: true, deep: true }
);
const handleSave = async () => {
if (!formRef.value || !groupStore.selectedGroupId) return;
try {
await formRef.value.validate();
isSaving.value = true;
await updateGroup(groupStore.selectedGroupId.toString(), {
name: formData.name,
description: formData.description,
is_default: formData.is_default,
});
ElMessage.success("保存成功");
// 刷新列表以获取最新数据
await groupStore.fetchGroups();
} catch (error) {
console.error("Failed to save group config:", error);
ElMessage.error("保存失败,请查看控制台");
} finally {
isSaving.value = false;
}
};
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -1,61 +0,0 @@
<template>
<div class="group-list" v-loading="groupStore.isLoading">
<el-menu
:default-active="groupStore.selectedGroupId?.toString() || undefined"
@select="handleSelect"
>
<el-menu-item
v-for="group in groupStore.groups"
:key="group.id"
:index="group.id.toString()"
>
<template #title>
<span>{{ group.name }}</span>
<el-tag v-if="group.is_default" size="small" style="margin-left: 8px"
>默认</el-tag
>
</template>
</el-menu-item>
</el-menu>
<div
v-if="!groupStore.isLoading && groupStore.groups.length === 0"
class="empty-state"
>
暂无分组
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { ElMenu, ElMenuItem, ElTag, vLoading } from "element-plus";
const groupStore = useGroupStore();
onMounted(() => {
// 组件挂载时获取分组数据
if (groupStore.groups.length === 0) {
groupStore.fetchGroups();
}
});
const handleSelect = (index: string) => {
groupStore.selectGroup(Number(index));
};
</script>
<style scoped>
.group-list {
border-right: 1px solid var(--el-border-color);
height: 100%;
}
.el-menu {
border-right: none;
}
.empty-state {
text-align: center;
color: var(--el-text-color-secondary);
padding-top: 20px;
}
</style>

View File

@@ -1,66 +0,0 @@
<template>
<el-form :inline="true" :model="filterData" class="log-filter-form">
<el-form-item label="分组">
<el-select v-model="filterData.group_id" placeholder="所有分组" clearable>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</el-form-item>
<el-form-item label="状态码">
<el-input v-model.number="filterData.status_code" placeholder="例如 200" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="applyFilters">查询</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useLogStore } from '@/stores/logStore';
import { useGroupStore } from '@/stores/groupStore';
import { storeToRefs } from 'pinia';
import type { LogQuery } from '@/api/logs';
const logStore = useLogStore();
const groupStore = useGroupStore();
const { groups } = storeToRefs(groupStore);
const filterData = reactive<LogQuery>({});
const dateRange = ref<[Date, Date] | null>(null);
onMounted(() => {
groupStore.fetchGroups();
});
const handleDateChange = (dates: [Date, Date] | null) => {
if (dates) {
filterData.start_time = dates[0].toISOString();
filterData.end_time = dates[1].toISOString();
} else {
filterData.start_time = undefined;
filterData.end_time = undefined;
}
};
const applyFilters = () => {
logStore.setFilters(filterData);
};
</script>
<style scoped>
.log-filter-form {
padding: 20px;
background-color: #f5f7fa;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<n-button quaternary round>退出</n-button>
</template>

View File

@@ -1,10 +1,5 @@
<template> <template>
<n-menu <n-menu mode="horizontal" :options="menuOptions" :value="activeMenu" responsive />
mode="horizontal"
:options="menuOptions"
:value="activeMenu"
responsive
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -24,15 +19,16 @@ const activeMenu = computed(() => route.name)
function renderMenuItem(key: string, label: string): MenuOption { function renderMenuItem(key: string, label: string): MenuOption {
return { return {
label: () => h( label: () =>
RouterLink, h(
{ RouterLink,
to: { {
name: key, to: {
} name: key,
}, },
{ default: () => label } },
), { default: () => label }
),
key, key,
} }
} }

View File

@@ -1,52 +0,0 @@
<template>
<div ref="chart" style="width: 100%; height: 400px;"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
import type { GroupRequestStat } from '@/types/models';
const props = defineProps<{
data: GroupRequestStat[];
}>();
const chart = ref<HTMLElement | null>(null);
let myChart: echarts.ECharts | null = null;
const initChart = () => {
if (chart.value) {
myChart = echarts.init(chart.value);
updateChart();
}
};
const updateChart = () => {
if (!myChart) return;
myChart.setOption({
title: {
text: '各分组请求量',
},
tooltip: {},
xAxis: {
data: props.data.map(item => item.group_name),
},
yAxis: {},
series: [
{
name: '请求量',
type: 'bar',
data: props.data.map(item => item.request_count),
},
],
});
};
onMounted(() => {
initChart();
});
watch(() => props.data, () => {
updateChart();
}, { deep: true });
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
<template>
<div class="main-layout">
<HeaderNav />
<main class="main-content">
<router-view />
</main>
<Footer />
</div>
</template>
<script setup lang="ts">
import HeaderNav from "./HeaderNav.vue";
import Footer from "./Footer.vue";
</script>
<style scoped>
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--el-bg-color-page);
}
.main-content {
flex: 1;
padding: 20px;
overflow: auto;
}
@media (max-width: 768px) {
.main-content {
padding: 10px;
}
}
</style>

View File

@@ -1,20 +1,7 @@
import naive from 'naive-ui'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router'
import './style.css' import './style.css'
import ElementPlus from 'element-plus' import router from './utils/router'
import 'element-plus/dist/index.css'
import { useAuthStore } from './stores/authStore'
const app = createApp(App) createApp(App).use(router).use(naive).mount('#app')
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 确保认证状态在应用启动时初始化
const authStore = useAuthStore()
authStore.initializeAuth()
app.mount('#app')

View File

@@ -1,78 +0,0 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import Dashboard from "../views/Dashboard.vue";
import Login from "../views/Login.vue";
import MainLayout from "../layouts/MainLayout.vue";
const routes = [
{
path: "/login",
name: "Login",
component: Login,
meta: { requiresAuth: false },
},
{
path: "/",
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "/dashboard",
name: "Dashboard",
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: "/groups",
name: "Groups",
component: () => import("../views/Groups.vue"),
meta: { requiresAuth: true },
},
{
path: "/logs",
name: "Logs",
component: () => import("../views/Logs.vue"),
meta: { requiresAuth: true },
},
{
path: "/settings",
name: "Settings",
component: () => import("../views/Settings.vue"),
meta: { requiresAuth: true },
},
],
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
// 路由守卫
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated;
const requiresAuth = to.matched.some(
(record) => record.meta.requiresAuth !== false
);
if (requiresAuth && !isAuthenticated) {
// 临时测试:自动登录
console.log("Auto-login for development testing");
authStore.login("test-key");
next();
} else if (to.name === "Login" && isAuthenticated) {
// 已登录用户访问登录页,重定向到仪表盘
next({ name: "Dashboard" });
} else {
// 正常访问
next();
}
});
export default router;

View File

@@ -1,59 +0,0 @@
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);
// 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);
}
function getAuthKey(): string {
return authKey.value;
}
function initializeAuth() {
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' };
}
}
// 在store初始化时自动恢复认证状态
initializeAuth();
return {
// State
authKey,
user,
// Computed
isAuthenticated,
// Actions
login,
logout,
getAuthKey,
initializeAuth,
};
});

View File

@@ -1,70 +0,0 @@
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>({
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,
});
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 getDashboardData(
filters.value.timeRange,
filters.value.groupId
);
stats.value = response;
} catch (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,
filters,
chartData,
fetchDashboardData,
startPolling,
stopPolling,
};
});

View File

@@ -1,118 +0,0 @@
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", () => {
// State
const groups = ref<Group[]>([]);
const selectedGroupId = ref<number | null>(null);
const isLoading = ref(false);
// Getters
const selectedGroupDetails = computed(() => {
if (!selectedGroupId.value) {
return null;
}
return groups.value.find((g) => g.id === selectedGroupId.value) || null;
});
// Actions
async function fetchGroups() {
isLoading.value = true;
try {
groups.value = await groupApi.fetchGroups();
// 如果没有选中的分组,或者选中的分组已不存在,则默认选中第一个
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);
} finally {
isLoading.value = false;
}
}
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 {
// State
groups,
selectedGroupId,
isLoading,
// Getters
selectedGroupDetails,
// Actions
fetchGroups,
selectGroup,
fetchGroupKeys,
createGroup,
updateGroup,
deleteGroup,
};
});

View File

@@ -1,169 +0,0 @@
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", () => {
// State
const keys = ref<APIKey[]>([]);
const selectedKeyIds = ref<number[]>([]);
const isLoading = ref(false);
// Actions
async function fetchKeys(groupId: string) {
if (!groupId) {
keys.value = [];
return;
}
isLoading.value = true;
try {
keys.value = await keyApi.fetchKeysInGroup(groupId);
} catch (error) {
console.error(`Failed to fetch keys for group ${groupId}:`, error);
keys.value = [];
} finally {
isLoading.value = false;
}
}
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;
}
}
async function updateKey(id: string, keyData: Partial<APIKey>) {
const groupStore = useGroupStore();
try {
await keyApi.updateKey(id, keyData);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to update key ${id}:`, error);
throw error;
}
}
async function deleteKey(id: string) {
const groupStore = useGroupStore();
try {
await keyApi.deleteKey(id);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to delete key ${id}:`, error);
throw error;
}
}
// 新增方法:更新密钥状态
async function updateKeyStatus(
id: number,
status: "active" | "inactive" | "error"
) {
try {
await keyApi.updateKey(id.toString(), { status });
const groupStore = useGroupStore();
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
}
} catch (error) {
console.error(`Failed to update key status ${id}:`, error);
throw error;
}
}
async function batchUpdateStatus(
ids: number[],
status: "active" | "inactive" | "error"
) {
const groupStore = useGroupStore();
try {
await keyApi.batchUpdateKeys(
ids.map((id) => id.toString()),
{ status }
);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch update key status:", error);
throw error;
}
}
// 新增方法:批量删除
async function batchDelete(ids: number[]) {
const groupStore = useGroupStore();
try {
await keyApi.batchDeleteKeys(ids.map((id) => id.toString()));
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch delete keys:", error);
throw error;
}
}
async function batchDeleteKeys(ids: string[]) {
const groupStore = useGroupStore();
try {
await keyApi.batchDeleteKeys(ids);
if (groupStore.selectedGroupId) {
await fetchKeys(groupStore.selectedGroupId.toString());
selectedKeyIds.value = []; // Clear selection after batch operation
}
} catch (error) {
console.error("Failed to batch delete keys:", error);
throw error;
}
}
return {
// State
keys,
selectedKeyIds,
isLoading,
// Actions
fetchKeys,
setSelectedKeys,
clearKeys,
createKey,
updateKey,
deleteKey,
updateKeyStatus,
batchUpdateStatus,
batchDelete,
batchDeleteKeys,
};
});

View File

@@ -1,61 +0,0 @@
import { defineStore } from 'pinia';
import { ref, reactive } from 'vue';
import { getLogs } from '@/api/logs';
import type { RequestLog, LogQuery, PaginatedLogs } from '@/api/logs';
export const useLogStore = defineStore('logs', () => {
const logs = ref<RequestLog[]>([]);
const loading = ref(false);
const pagination = reactive({
page: 1,
size: 10,
total: 0,
});
const filters = reactive<LogQuery>({});
const fetchLogs = async () => {
loading.value = true;
try {
const query: LogQuery = {
...filters,
page: pagination.page,
size: pagination.size,
};
const response: PaginatedLogs = await getLogs(query);
logs.value = response.data;
pagination.total = response.total;
} catch (error) {
console.error('Failed to fetch logs:', error);
} finally {
loading.value = false;
}
};
const setFilters = (newFilters: LogQuery) => {
Object.assign(filters, newFilters);
pagination.page = 1;
fetchLogs();
};
const setPage = (page: number) => {
pagination.page = page;
fetchLogs();
};
const setSize = (size: number) => {
pagination.size = size;
pagination.page = 1;
fetchLogs();
};
return {
logs,
loading,
pagination,
filters,
fetchLogs,
setFilters,
setPage,
setSize,
};
});

View File

@@ -1,67 +0,0 @@
import { defineStore } from 'pinia';
import { getSystemSettings, updateSystemSettings } from '@/api/settings';
import type { SystemSettings } from '@/types/models';
import { ElMessage } from 'element-plus';
interface SettingsState {
systemSettings: SystemSettings | null;
loading: boolean;
error: any;
errors: Record<string, string>; // For field-specific validation errors
}
export const useSettingStore = defineStore('setting', {
state: (): SettingsState => ({
systemSettings: null,
loading: false,
error: null,
errors: {},
}),
actions: {
async fetchSystemSettings() {
this.loading = true;
this.error = null;
try {
const response = await getSystemSettings();
this.systemSettings = response.data;
} catch (error) {
this.error = error;
ElMessage.error('Failed to fetch system settings.');
} finally {
this.loading = false;
}
},
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 updateSystemSettings(this.systemSettings);
await this.fetchSystemSettings(); // Refresh state
ElMessage.success('System settings updated successfully.');
} catch (error) {
this.error = error;
ElMessage.error('Failed to update system settings.');
} finally {
this.loading = false;
}
},
// Action to reset settings to their original state (fetched from server)
async resetSystemSettings() {
await this.fetchSystemSettings();
},
},
});

View File

@@ -1,136 +1,82 @@
/* 全局样式重置和基础设置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
font-family: "Inter", "SF Pro Display", system-ui, -apple-system, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
/* 使用亮色主题 */
color-scheme: light;
color: #1f2937;
background-color: #ffffff;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html,
body {
height: 100%;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
body { body {
margin: 0; margin: 0;
width: 100%; display: flex;
height: 100vh; place-items: center;
overflow-x: hidden; min-width: 320px;
min-height: 100vh;
} }
#app { #app {
max-width: 1280px;
width: 100%; width: 100%;
height: 100vh; margin: 0 auto;
padding: 2rem;
min-height: 100vh;
}
.flex {
display: flex; display: flex;
}
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column; flex-direction: column;
} }
/* 链接样式 */ .justify-start {
a { justify-content: flex-start;
font-weight: 500;
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
} }
a:hover { .justify-center {
color: #1d4ed8; justify-content: center;
} }
/* 标题样式 */ .justify-end {
h1, justify-content: flex-end;
h2,
h3,
h4,
h5,
h6 {
color: #1f2937;
font-weight: 600;
line-height: 1.2;
margin: 0;
} }
h1 { .justify-between {
font-size: 2.5rem; justify-content: space-between;
} }
h2 { .items-start {
font-size: 2rem; align-items: flex-start;
} }
h3 { .items-center {
font-size: 1.5rem; align-items: center;
} }
/* 按钮基础样式重置 */ .items-end {
button { align-items: flex-end;
border: none;
background: none;
padding: 0;
cursor: pointer;
font-family: inherit;
} }
/* 表单元素样式 */ .flex-wrap {
input, flex-wrap: wrap;
textarea,
select {
font-family: inherit;
} }
/* 滚动条样式 */ .flex-nowrap {
::-webkit-scrollbar { flex-wrap: nowrap;
width: 6px;
height: 6px;
} }
::-webkit-scrollbar-track { .grow {
background: #f1f5f9; flex-grow: 1;
border-radius: 3px;
} }
::-webkit-scrollbar-thumb { .shrink {
background: #cbd5e1; flex-shrink: 1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 工具类 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }

View File

@@ -1,175 +0,0 @@
// Based on internal/models/types.go
// 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: 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 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; // 对应后端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; // uint -> number
key_id: number; // uint -> number
source_ip: string;
status_code: number;
request_path: string;
request_body_snippet: string;
}
// Corresponds to the SystemSetting struct in Go
export interface SystemSetting {
id: number;
setting_key: string;
setting_value: string;
description: string;
created_at: string;
updated_at: string;
}
export interface AuthUser {
key: string;
isAuthenticated: boolean;
}
export interface User {
id: string;
username: string;
}
// Represents a simplified setting for frontend forms
export interface Setting {
key: string;
value: string;
}
// Corresponds to the structured system settings
export interface CorsSettings {
allowed_origins: string;
}
export interface TimeoutSettings {
read: number;
write: number;
}
export interface SystemSettings {
port: number;
cors: CorsSettings;
timeout: TimeoutSettings;
}
// A generic type for different setting categories
export type SettingCategory =
| "system"
| "auth"
| "performance"
| "logs"
| "group";
// 数据转换适配器 - 兼容旧数据格式
export const adaptLegacyKey = (legacyKey: any): APIKey => ({
id: Number(legacyKey.id),
group_id: Number(legacyKey.group_id),
key_value: legacyKey.api_key || legacyKey.key_value,
status: legacyKey.is_active ? "active" : "inactive",
request_count: legacyKey.usage || legacyKey.request_count || 0,
failure_count: legacyKey.failure_count || 0,
last_used_at: legacyKey.last_used_at,
created_at: legacyKey.created_at,
updated_at: legacyKey.updated_at,
});
export const adaptLegacyGroup = (legacyGroup: any): Group => ({
id: Number(legacyGroup.id),
name: legacyGroup.name,
description: legacyGroup.description,
channel_type: legacyGroup.channel_type || "openai",
config:
typeof legacyGroup.config === "string"
? JSON.parse(legacyGroup.config || "{}")
: legacyGroup.config || {},
api_keys: legacyGroup.api_keys?.map(adaptLegacyKey),
created_at: legacyGroup.created_at,
updated_at: legacyGroup.updated_at,
});
// 工具函数
export const maskKey = (key: string): string => {
if (!key || key.length < 8) return "****";
return key.substring(0, 4) + "****" + key.substring(key.length - 4);
};
export const getStatusColor = (status: string): string => {
switch (status) {
case "active":
return "success";
case "inactive":
return "warning";
case "error":
return "danger";
default:
return "info";
}
};
export const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + "K";
}
return num.toString();
};

View File

@@ -3,7 +3,7 @@ import axios from 'axios'
const http = axios.create({ const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000, timeout: 10000,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}) })
// 请求拦截器 // 请求拦截器

View File

@@ -1,32 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
path: '/', path: '/',
name: 'dashboard', name: 'dashboard',
component: () => import('@/views/Dashboard.vue') component: () => import('@/views/Dashboard.vue'),
}, },
{ {
path: '/keys', path: '/keys',
name: 'keys', name: 'keys',
component: () => import('@/views/Keys.vue') component: () => import('@/views/Keys.vue'),
}, },
{ {
path: '/logs', path: '/logs',
name: 'logs', name: 'logs',
component: () => import('@/views/Logs.vue') component: () => import('@/views/Logs.vue'),
}, },
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
component: () => import('@/views/Settings.vue') component: () => import('@/views/Settings.vue'),
}, },
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes routes,
}) })
export default router export default router

View File

@@ -1,8 +1,8 @@
import { reactive, toRef, isRef } from 'vue' import { isRef, reactive, toRef, type Ref } from 'vue'
import type { Ref } from 'vue'
type IntializeFunc<T> = (() => T | Ref<T>) type IntializeFunc<T> = () => T | Ref<T>
type InitializeValue<T> = T | Ref<T> | IntializeFunc<T> type InitializeValue<T> = T | Ref<T> | IntializeFunc<T>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type GlobalState = Record<string, any> type GlobalState = Record<string, any>
const globalState = reactive<GlobalState>({}) const globalState = reactive<GlobalState>({})

View File

@@ -1,163 +1,15 @@
<template> <template>
<div class="dashboard-container"> <base-info-card />
<div class="dashboard-header"> <line-chart class="chart" />
<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>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from "vue"; import BaseInfoCard from '@/components/BaseInfoCard.vue'
import { storeToRefs } from "pinia"; import LineChart from '@/components/LineChart.vue'
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 groupStore = useGroupStore();
const { stats, loading, filters } = storeToRefs(dashboardStore);
const onFilterChange = () => {
dashboardStore.fetchDashboardData();
};
onMounted(() => {
dashboardStore.startPolling();
groupStore.fetchGroups(); // 获取分组列表用于筛选
});
onUnmounted(() => {
dashboardStore.stopPolling();
});
</script> </script>
<style scoped> <style scoped>
.dashboard-container { .chart {
padding: 24px; margin-top: 20px;
max-width: 1200px;
margin: 0 auto;
}
.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: 1fr 2fr;
gap: 24px;
align-items: start;
}
.quick-actions-section {
min-height: 200px;
}
.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> </style>

View File

@@ -1,525 +0,0 @@
<template>
<div class="groups-view">
<el-row :gutter="20" class="main-layout">
<!-- 左侧分组列表 -->
<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>
</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 { 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>
.groups-view {
height: 100%;
padding: 20px;
box-sizing: border-box;
}
.main-layout {
height: 100%;
}
.left-panel {
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%;
}
.group-info-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--el-border-color-light);
}
.group-title {
display: flex;
align-items: center;
gap: 12px;
}
.group-title h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.card-actions {
display: flex;
gap: 8px;
}
.card-content {
padding: 20px;
}
.group-description {
margin: 0 0 20px 0;
color: var(--el-text-color-regular);
line-height: 1.5;
}
.group-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
padding: 12px;
background-color: var(--el-bg-color-page);
border-radius: 6px;
}
.stat-label {
display: block;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
display: block;
font-size: 20px;
font-weight: 600;
color: var(--el-color-primary);
}
.group-config {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.group-config h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: 13px;
color: var(--el-text-color-regular);
}
.config-value {
font-size: 13px;
color: var(--el-text-color-primary);
font-family: monospace;
}
.keys-section {
flex: 1;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
min-height: 0;
display: flex;
flex-direction: column;
}
.section-header {
margin-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-light);
padding-bottom: 12px;
}
.section-header h4 {
margin: 0;
font-size: 16px;
color: var(--el-text-color-primary);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.groups-view {
padding: 10px;
}
.main-layout {
flex-direction: column;
}
.left-panel {
height: auto;
margin-bottom: 20px;
}
.left-content {
height: 300px;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.group-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.group-stats {
grid-template-columns: 1fr;
}
.config-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
</style>

View File

@@ -1,192 +1,3 @@
<template> <template>
<div class="login-container"> <h1>Login</h1>
<el-card class="login-card">
<template #header>
<div class="login-header">
<h2>GPT Load - 登录</h2>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="authKey">
<el-input
v-model="loginForm.authKey"
type="password"
placeholder="请输入认证密钥"
size="large"
show-password
clearable
@keyup.enter="handleLogin"
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
native-type="submit"
class="login-button"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
class="error-alert"
/>
</el-card>
</div>
</template> </template>
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance } from 'element-plus'
import { Key } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/authStore'
import { login as loginAPI } from '../api/auth'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref<FormInstance>()
const loading = ref(false)
const errorMessage = ref('')
const loginForm = reactive({
authKey: ''
})
const loginRules = {
authKey: [
{ required: true, message: '请输入认证密钥', trigger: 'blur' },
{ min: 1, message: '认证密钥不能为空', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
const valid = await loginFormRef.value.validate()
if (!valid) return
loading.value = true
errorMessage.value = ''
// 调用后端API进行认证
const response = await loginAPI(loginForm.authKey)
if (response.success) {
// 认证成功,保存认证密钥
authStore.login(loginForm.authKey)
ElMessage.success('登录成功')
// 获取重定向路径,默认跳转到仪表盘
const redirect = router.currentRoute.value.query.redirect as string
await router.push(redirect || '/dashboard')
} else {
// 认证失败,显示错误信息
errorMessage.value = response.message || '认证失败,请检查认证密钥'
}
} catch (error: any) {
console.error('Login error:', error)
// 处理网络错误和API错误
if (error.response?.status === 401) {
errorMessage.value = '认证密钥错误,请重新输入'
} else if (error.response?.data?.message) {
errorMessage.value = error.response.data.message
} else if (error.message) {
errorMessage.value = `登录失败: ${error.message}`
} else {
errorMessage.value = '登录失败,请检查网络连接'
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
border-radius: 12px;
}
.login-header {
text-align: center;
}
.login-header h2 {
margin: 0;
color: #303133;
font-weight: 600;
}
.login-form {
padding: 0 20px;
}
.login-button {
width: 100%;
height: 45px;
font-size: 16px;
font-weight: 500;
}
.error-alert {
margin-top: 16px;
}
:deep(.el-card__header) {
padding: 20px 20px 10px 20px;
border-bottom: 1px solid #ebeef5;
}
:deep(.el-card__body) {
padding: 30px 20px 20px 20px;
}
:deep(.el-input__inner) {
height: 45px;
font-size: 14px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -1,481 +1,3 @@
<template> <template>
<div class="logs-view"> <div>logs</div>
<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> </template>
<script setup lang="ts">
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 groupStore = useGroupStore();
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) => {
pagination.currentPage = page;
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.currentPage = 1;
};
onMounted(() => {
// 加载分组数据用于筛选
groupStore.fetchGroups();
// 加载日志数据
// TODO: 实现真实的日志加载逻辑
// logStore.fetchLogs();
});
</script>
<style scoped>
.logs-view {
padding: 24px;
background-color: var(--el-bg-color-page);
min-height: 100vh;
}
.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;
flex-direction: column;
gap: 8px;
}
.filter-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.filter-actions {
display: flex;
gap: 8px;
}
.logs-table {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.timestamp-cell {
font-family: monospace;
font-size: 13px;
}
.group-name {
font-weight: 500;
}
.request-path {
font-family: monospace;
font-size: 12px;
color: var(--el-text-color-regular);
}
.ip-address {
font-family: monospace;
font-size: 13px;
}
.request-detail {
max-height: 60vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section h4 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.request-body-container {
background-color: var(--el-bg-color-page);
border-radius: 6px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
}
.request-body {
margin: 0;
font-family: "Monaco", "Consolas", monospace;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 768px) {
.logs-view {
padding: 16px;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filter-actions {
justify-content: stretch;
}
.filter-actions .el-button {
flex: 1;
}
}
</style>

View File

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

View File

@@ -1,113 +1,3 @@
<template> <template>
<div class="flex h-full bg-gray-100"> <div>settings</div>
<!-- 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> </template>
<script setup lang="ts">
import { computed, onMounted, shallowRef } from 'vue';
import { useSettingStore } from '@/stores/settingStore';
import { storeToRefs } from 'pinia';
import SystemSettings from '@/components/business/settings/SystemSettings.vue';
import GroupSettings from '@/components/business/settings/GroupSettings.vue';
const settingStore = useSettingStore();
const { loading } = storeToRefs(settingStore);
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(() => {
// Fetch initial data for the default tab
settingStore.fetchSystemSettings();
});
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
};
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>

View File

@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }

Some files were not shown because too many files have changed in this diff Show More