feat: 重构前端
This commit is contained in:
25
.editorconfig
Normal file
25
.editorconfig
Normal 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
42
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
|
||||
```
|
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
58
.github/pull_request_template.md
vendored
Normal 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
2
.gitignore
vendored
@@ -120,7 +120,6 @@ coverage
|
||||
.vscode-test
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -191,7 +190,6 @@ tramp
|
||||
.\#*
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# JetBrains IDEs
|
||||
|
49
.vscode/extensions.json
vendored
Normal file
49
.vscode/extensions.json
vendored
Normal 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
87
.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
5
Makefile
5
Makefile
@@ -43,10 +43,9 @@ build-all: clean ## 为所有支持的平台构建二进制文件
|
||||
.PHONY: run
|
||||
run: ## 构建前端并运行服务器
|
||||
@echo "--- Building frontend... ---"
|
||||
cd web && npm install && npm run build
|
||||
@echo "--- Preparing backend... ---"
|
||||
@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... ---"
|
||||
go run $(MAIN_PATH)/main.go
|
||||
|
||||
|
@@ -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
1804
fe/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
1307
fe/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Layout from '@/components/Layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout />
|
||||
</template>
|
@@ -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 |
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<n-button quaternary round>
|
||||
退出
|
||||
</n-button>
|
||||
</template>
|
@@ -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')
|
@@ -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;
|
||||
}
|
@@ -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>
|
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<h1>Login</h1>
|
||||
</template>
|
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>logs</div>
|
||||
</template>
|
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>settings</div>
|
||||
</template>
|
1
fe/src/vite-env.d.ts
vendored
1
fe/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
@@ -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"]
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
@@ -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"]
|
||||
}
|
@@ -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
10
web/.browserslistrc
Normal 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
24
web/.gitignore
vendored
@@ -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
7
web/.lintstagedrc
Normal 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
30
web/.npmrc
Normal 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
43
web/.prettierignore
Normal 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
48
web/.prettierrc
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@@ -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
102
web/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
]
|
@@ -1,25 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<!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>GPT Load - 负载均衡管理系统</title>
|
||||
<style>
|
||||
/* 防止页面加载时出现布局闪烁 */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
3446
web/package-lock.json
generated
3446
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,61 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "gpt-load-frontend",
|
||||
"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",
|
||||
"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": {
|
||||
"dev": "vite",
|
||||
"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": {
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.10.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.10.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.17",
|
||||
"axios": "^1.9.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.7",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@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",
|
||||
"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",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
|
2286
web/pnpm-lock.yaml
generated
Normal file
2286
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,7 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App.vue 现在只需要渲染路由视图
|
||||
// 路由器会决定是渲染 Login 组件还是 MainLayout 组件
|
||||
console.log("App.vue loaded");
|
||||
import Layout from '@/components/Layout.vue'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<layout />
|
||||
</template>
|
||||
|
@@ -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;
|
||||
};
|
@@ -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()}`);
|
||||
};
|
@@ -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);
|
||||
};
|
@@ -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;
|
@@ -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);
|
||||
};
|
@@ -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 });
|
||||
};
|
@@ -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);
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
3
web/src/components/Logout.vue
Normal file
3
web/src/components/Logout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<n-button quaternary round>退出</n-button>
|
||||
</template>
|
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<n-menu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:value="activeMenu"
|
||||
responsive
|
||||
/>
|
||||
<n-menu mode="horizontal" :options="menuOptions" :value="activeMenu" responsive />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -24,15 +19,16 @@ const activeMenu = computed(() => route.name)
|
||||
|
||||
function renderMenuItem(key: string, label: string): MenuOption {
|
||||
return {
|
||||
label: () => h(
|
||||
RouterLink,
|
||||
{
|
||||
to: {
|
||||
name: key,
|
||||
}
|
||||
},
|
||||
{ default: () => label }
|
||||
),
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
to: {
|
||||
name: key,
|
||||
},
|
||||
},
|
||||
{ default: () => label }
|
||||
),
|
||||
key,
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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.5、GPT-4 等模型</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
<el-radio value="gemini">
|
||||
<div class="channel-option">
|
||||
<span class="channel-name">Gemini</span>
|
||||
<span class="channel-desc">Google 的 Gemini 模型</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">配置设置</el-divider>
|
||||
|
||||
<el-form-item label="上游地址" prop="config.upstream_url">
|
||||
<el-input
|
||||
v-model="formData.config.upstream_url"
|
||||
placeholder="请输入API上游地址"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
例如:https://api.openai.com 或
|
||||
https://generativelanguage.googleapis.com
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="超时时间">
|
||||
<el-input-number
|
||||
v-model="formData.config.timeout"
|
||||
:min="1000"
|
||||
:max="300000"
|
||||
:step="1000"
|
||||
placeholder="请输入超时时间"
|
||||
/>
|
||||
<span class="input-suffix">毫秒</span>
|
||||
<div class="form-tip">请求超时时间,范围:1秒 - 5分钟,默认30秒</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大令牌数">
|
||||
<el-input-number
|
||||
v-model="formData.config.max_tokens"
|
||||
:min="1"
|
||||
:max="32000"
|
||||
placeholder="请输入最大令牌数"
|
||||
/>
|
||||
<div class="form-tip">单次请求最大令牌数,留空使用模型默认值</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 高级配置 -->
|
||||
<el-collapse v-model="activeCollapse">
|
||||
<el-collapse-item title="高级配置" name="advanced">
|
||||
<el-form-item label="请求头">
|
||||
<div class="config-editor">
|
||||
<el-input
|
||||
v-model="headersText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入自定义请求头配置(JSON格式)"
|
||||
@blur="validateHeaders"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
格式:{"Authorization": "Bearer token", "Custom-Header":
|
||||
"value"}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="其他配置">
|
||||
<div class="config-editor">
|
||||
<el-input
|
||||
v-model="otherConfigText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入其他配置项(JSON格式)"
|
||||
@blur="validateOtherConfig"
|
||||
/>
|
||||
<div class="form-tip">其他自定义配置参数,将合并到分组配置中</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
{{ isEdit ? "保存修改" : "创建分组" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick } from "vue";
|
||||
import {
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRadio,
|
||||
ElButton,
|
||||
ElDivider,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElMessage,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
} from "element-plus";
|
||||
import type { Group, GroupConfig } from "@/types/models";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
groupData?: Group | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
groupData: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", data: any): void;
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const dialogVisible = ref(props.visible);
|
||||
const submitting = ref(false);
|
||||
const activeCollapse = ref<string[]>([]);
|
||||
const headersText = ref("");
|
||||
const otherConfigText = ref("");
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => !!props.groupData);
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<{
|
||||
name: string;
|
||||
description: string;
|
||||
channel_type: "openai" | "gemini";
|
||||
config: GroupConfig;
|
||||
}>({
|
||||
name: "",
|
||||
description: "",
|
||||
channel_type: "openai",
|
||||
config: {
|
||||
upstream_url: "",
|
||||
timeout: 30000,
|
||||
max_tokens: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入分组名称", trigger: "blur" },
|
||||
{ min: 2, max: 50, message: "分组名称长度为2-50个字符", trigger: "blur" },
|
||||
],
|
||||
channel_type: [
|
||||
{ required: true, message: "请选择渠道类型", trigger: "change" },
|
||||
],
|
||||
"config.upstream_url": [
|
||||
{ required: true, message: "请输入上游地址", trigger: "blur" },
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: "请输入有效的HTTP/HTTPS地址",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
dialogVisible.value = val;
|
||||
if (val) {
|
||||
resetForm();
|
||||
if (props.groupData) {
|
||||
loadGroupData();
|
||||
} else {
|
||||
setDefaultConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(dialogVisible, (val) => {
|
||||
emit("update:visible", val);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => formData.channel_type,
|
||||
(newType) => {
|
||||
setDefaultConfig(newType);
|
||||
}
|
||||
);
|
||||
|
||||
// 方法
|
||||
const resetForm = () => {
|
||||
formData.name = "";
|
||||
formData.description = "";
|
||||
formData.channel_type = "openai";
|
||||
formData.config = {
|
||||
upstream_url: "",
|
||||
timeout: 30000,
|
||||
max_tokens: undefined,
|
||||
};
|
||||
headersText.value = "";
|
||||
otherConfigText.value = "";
|
||||
activeCollapse.value = [];
|
||||
submitting.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
};
|
||||
|
||||
const setDefaultConfig = (channelType?: "openai" | "gemini") => {
|
||||
const type = channelType || formData.channel_type;
|
||||
|
||||
if (!isEdit.value) {
|
||||
switch (type) {
|
||||
case "openai":
|
||||
formData.config.upstream_url = "https://api.openai.com";
|
||||
break;
|
||||
case "gemini":
|
||||
formData.config.upstream_url =
|
||||
"https://generativelanguage.googleapis.com";
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadGroupData = () => {
|
||||
if (props.groupData) {
|
||||
formData.name = props.groupData.name;
|
||||
formData.description = props.groupData.description;
|
||||
formData.channel_type = props.groupData.channel_type;
|
||||
formData.config = { ...props.groupData.config };
|
||||
|
||||
// 解析高级配置
|
||||
if (props.groupData.config.headers) {
|
||||
headersText.value = JSON.stringify(
|
||||
props.groupData.config.headers,
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// 提取其他配置(排除已知字段)
|
||||
const { upstream_url, timeout, max_tokens, headers, ...otherConfig } =
|
||||
props.groupData.config;
|
||||
if (Object.keys(otherConfig).length > 0) {
|
||||
otherConfigText.value = JSON.stringify(otherConfig, null, 2);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateHeaders = () => {
|
||||
if (!headersText.value.trim()) return;
|
||||
|
||||
try {
|
||||
JSON.parse(headersText.value);
|
||||
} catch {
|
||||
ElMessage.error("请求头配置格式错误,请检查JSON语法");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateOtherConfig = () => {
|
||||
if (!otherConfigText.value.trim()) return;
|
||||
|
||||
try {
|
||||
JSON.parse(otherConfigText.value);
|
||||
} catch {
|
||||
ElMessage.error("其他配置格式错误,请检查JSON语法");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateForm = async (): Promise<boolean> => {
|
||||
if (!formRef.value) return false;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
|
||||
// 验证高级配置
|
||||
if (headersText.value.trim() && !validateHeaders()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (otherConfigText.value.trim() && !validateOtherConfig()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildConfigData = () => {
|
||||
const config: GroupConfig = {
|
||||
upstream_url: formData.config.upstream_url,
|
||||
timeout: formData.config.timeout,
|
||||
};
|
||||
|
||||
if (formData.config.max_tokens) {
|
||||
config.max_tokens = formData.config.max_tokens;
|
||||
}
|
||||
|
||||
// 添加自定义请求头
|
||||
if (headersText.value.trim()) {
|
||||
try {
|
||||
config.headers = JSON.parse(headersText.value);
|
||||
} catch {
|
||||
// 已在验证中处理
|
||||
}
|
||||
}
|
||||
|
||||
// 添加其他配置
|
||||
if (otherConfigText.value.trim()) {
|
||||
try {
|
||||
const otherConfig = JSON.parse(otherConfigText.value);
|
||||
Object.assign(config, otherConfig);
|
||||
} catch {
|
||||
// 已在验证中处理
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!(await validateForm())) return;
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
const saveData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
channel_type: formData.channel_type,
|
||||
config: buildConfigData(),
|
||||
};
|
||||
|
||||
if (isEdit.value) {
|
||||
emit("save", {
|
||||
...saveData,
|
||||
id: props.groupData!.id,
|
||||
});
|
||||
} else {
|
||||
emit("save", saveData);
|
||||
}
|
||||
|
||||
ElMessage.success(isEdit.value ? "分组更新成功" : "分组创建成功");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Save group failed:", error);
|
||||
ElMessage.error("操作失败,请重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (submitting.value) {
|
||||
ElMessage.warning("操作进行中,请稍后");
|
||||
return;
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
resetForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.channel-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.channel-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
margin-left: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
:deep(.el-radio__input) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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">
|
||||
© {{ 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>
|
@@ -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>
|
@@ -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>
|
@@ -1,20 +1,7 @@
|
||||
import naive from 'naive-ui'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import router from './utils/router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
// 确保认证状态在应用启动时初始化
|
||||
const authStore = useAuthStore()
|
||||
authStore.initializeAuth()
|
||||
|
||||
app.mount('#app')
|
||||
createApp(App).use(router).use(naive).mount('#app')
|
||||
|
@@ -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;
|
@@ -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,
|
||||
};
|
||||
});
|
@@ -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,
|
||||
};
|
||||
});
|
@@ -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,
|
||||
};
|
||||
});
|
@@ -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,
|
||||
};
|
||||
});
|
@@ -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,
|
||||
};
|
||||
});
|
@@ -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();
|
||||
},
|
||||
},
|
||||
});
|
@@ -1,136 +1,82 @@
|
||||
/* 全局样式重置和基础设置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
: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;
|
||||
font-weight: 400;
|
||||
|
||||
/* 使用亮色主题 */
|
||||
color-scheme: light;
|
||||
color: #1f2937;
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #1d4ed8;
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 按钮基础样式重置 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 表单元素样式 */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
.flex-nowrap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
@@ -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();
|
||||
};
|
@@ -3,7 +3,7 @@ import axios from 'axios'
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// 请求拦截器
|
@@ -1,32 +1,31 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/Dashboard.vue')
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'keys',
|
||||
component: () => import('@/views/Keys.vue')
|
||||
component: () => import('@/views/Keys.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/Logs.vue')
|
||||
component: () => import('@/views/Logs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/Settings.vue')
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
@@ -1,8 +1,8 @@
|
||||
import { reactive, toRef, isRef } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { isRef, reactive, toRef, type Ref } from 'vue'
|
||||
|
||||
type IntializeFunc<T> = (() => T | Ref<T>)
|
||||
type IntializeFunc<T> = () => T | Ref<T>
|
||||
type InitializeValue<T> = T | Ref<T> | IntializeFunc<T>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GlobalState = Record<string, any>
|
||||
|
||||
const globalState = reactive<GlobalState>({})
|
@@ -1,163 +1,15 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h1>仪表盘</h1>
|
||||
<p>查看您账户的总体使用情况和统计数据。</p>
|
||||
</div>
|
||||
|
||||
<LoadingSpinner v-if="loading && !stats.total_keys" />
|
||||
|
||||
<div v-else class="dashboard-content">
|
||||
<!-- 统计卡片 -->
|
||||
<StatsCards />
|
||||
|
||||
<!-- 快捷操作和筛选 -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="quick-actions-section">
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div class="filters-section">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<h3>筛选图表</h3>
|
||||
</template>
|
||||
<div class="filter-controls">
|
||||
<!-- 时间范围筛选 -->
|
||||
<el-select
|
||||
v-model="filters.timeRange"
|
||||
@change="onFilterChange"
|
||||
placeholder="选择时间范围"
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option label="过去 24 小时" value="24h" />
|
||||
<el-option label="过去 7 天" value="7d" />
|
||||
<el-option label="过去 30 天" value="30d" />
|
||||
</el-select>
|
||||
<!-- 分组筛选 -->
|
||||
<el-select
|
||||
v-model="filters.groupId"
|
||||
@change="onFilterChange"
|
||||
placeholder="选择分组"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="group in groupStore.groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="group.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求统计图表 -->
|
||||
<RequestChart />
|
||||
</div>
|
||||
</div>
|
||||
<base-info-card />
|
||||
<line-chart class="chart" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
import { useGroupStore } from "@/stores/groupStore";
|
||||
import StatsCards from "@/components/business/dashboard/StatsCards.vue";
|
||||
import RequestChart from "@/components/business/dashboard/RequestChart.vue";
|
||||
import QuickActions from "@/components/business/dashboard/QuickActions.vue";
|
||||
import LoadingSpinner from "@/components/common/LoadingSpinner.vue";
|
||||
|
||||
const dashboardStore = useDashboardStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
const { stats, loading, filters } = storeToRefs(dashboardStore);
|
||||
|
||||
const onFilterChange = () => {
|
||||
dashboardStore.fetchDashboardData();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
dashboardStore.startPolling();
|
||||
groupStore.fetchGroups(); // 获取分组列表用于筛选
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
dashboardStore.stopPolling();
|
||||
});
|
||||
import BaseInfoCard from '@/components/BaseInfoCard.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
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;
|
||||
}
|
||||
.chart {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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>
|
@@ -1,192 +1,3 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<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>
|
||||
<h1>Login</h1>
|
||||
</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>
|
@@ -1,481 +1,3 @@
|
||||
<template>
|
||||
<div class="logs-view">
|
||||
<div class="page-header">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">请求日志</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
查看和管// 筛选器 const filters = reactive({ dateRange: [] as [Date,
|
||||
Date] | [], groupId: undefined as number | undefined, statusCode:
|
||||
undefined as number | undefined, keyword: '', });志记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<div class="filters-card">
|
||||
<el-card shadow="never">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">时间范围</label>
|
||||
<DateRangePicker v-model="filters.dateRange" />
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">分组</label>
|
||||
<el-select
|
||||
v-model="filters.groupId"
|
||||
placeholder="选择分组"
|
||||
clearable
|
||||
@clear="filters.groupId = ''"
|
||||
>
|
||||
<el-option
|
||||
v-for="group in groupStore.groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="group.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">状态码</label>
|
||||
<el-select
|
||||
v-model="filters.statusCode"
|
||||
placeholder="选择状态码"
|
||||
clearable
|
||||
@clear="filters.statusCode = ''"
|
||||
>
|
||||
<el-option label="200 - 成功" :value="200" />
|
||||
<el-option label="400 - 请求错误" :value="400" />
|
||||
<el-option label="401 - 未授权" :value="401" />
|
||||
<el-option label="429 - 限流" :value="429" />
|
||||
<el-option label="500 - 服务器错误" :value="500" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">搜索</label>
|
||||
<SearchInput
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索IP地址或请求路径..."
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" @click="applyFilters">应用筛选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<div class="logs-table">
|
||||
<DataTable
|
||||
:data="filteredLogs"
|
||||
:columns="tableColumns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
>
|
||||
<!-- 时间戳列 -->
|
||||
<template #timestamp="{ row }">
|
||||
<div class="timestamp-cell">
|
||||
{{ formatTimestamp(row.timestamp) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态码列 -->
|
||||
<template #status_code="{ row }">
|
||||
<el-tag :type="getStatusType(row.status_code)">
|
||||
{{ row.status_code }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<!-- 分组列 -->
|
||||
<template #group="{ row }">
|
||||
<span class="group-name">
|
||||
{{ getGroupName(row.group_id) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 请求路径列 -->
|
||||
<template #request_path="{ row }">
|
||||
<el-tooltip placement="top" :content="row.request_path">
|
||||
<span class="request-path">
|
||||
{{ truncateText(row.request_path, 50) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- IP地址列 -->
|
||||
<template #source_ip="{ row }">
|
||||
<span class="ip-address">{{ row.source_ip }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 请求体预览列 -->
|
||||
<template #request_body="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
@click="showRequestBody(row)"
|
||||
v-if="row.request_body_snippet"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<span v-else class="text-gray-400">无内容</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 请求详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="请求详情" width="800px">
|
||||
<div v-if="selectedLog" class="request-detail">
|
||||
<div class="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="时间">
|
||||
{{ formatTimestamp(selectedLog.timestamp) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态码">
|
||||
<el-tag :type="getStatusType(selectedLog.status_code)">
|
||||
{{ selectedLog.status_code }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ selectedLog.source_ip }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分组">
|
||||
{{ getGroupName(selectedLog.group_id) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求路径" :span="2">
|
||||
<code>{{ selectedLog.request_path }}</code>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedLog.request_body_snippet">
|
||||
<h4>请求内容</h4>
|
||||
<div class="request-body-container">
|
||||
<pre class="request-body">{{
|
||||
selectedLog.request_body_snippet
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div>logs</div>
|
||||
</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>
|
||||
|
@@ -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>
|
@@ -1,113 +1,3 @@
|
||||
<template>
|
||||
<div class="flex h-full bg-gray-100">
|
||||
<!-- Left Navigation -->
|
||||
<aside class="w-64 bg-white p-4 shadow-md">
|
||||
<h2 class="text-xl font-bold mb-6">设置</h2>
|
||||
<nav class="space-y-2">
|
||||
<a
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
@click="activeTab = item.component"
|
||||
:class="[
|
||||
'block px-4 py-2 rounded-md cursor-pointer',
|
||||
activeTab === item.component
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Right Content -->
|
||||
<main class="flex-1 p-8 overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="activeComponent" />
|
||||
</transition>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-8 flex justify-end space-x-4">
|
||||
<button
|
||||
@click="handleReset"
|
||||
class="px-6 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="loading"
|
||||
class="px-6 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div>settings</div>
|
||||
</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>
|
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user