mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 全新的Vue3管理后台(admin-spa)和路由重构
🎨 新增功能: - 使用Vue3 + Vite构建的全新管理后台界面 - 支持Tab切换的API统计页面(统计查询/使用教程) - 优雅的胶囊式Tab切换设计 - 同步了PR #106的会话窗口管理功能 - 完整的响应式设计和骨架屏加载状态 🔧 路由调整: - 新版管理后台部署在 /admin-next/ 路径 - 将根路径 / 重定向到 /admin-next/api-stats - 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由 - 将 /apiStats 页面路由重定向到新版,保留API端点 🗑️ 清理工作: - 删除旧版 web/admin/ 静态文件 - 删除旧版 web/apiStats/ 静态文件 - 清理相关的文件服务代码 🐛 修复问题: - 修复重定向循环问题 - 修复环境变量配置 - 修复路由404错误 - 优化构建配置 🚀 生成方式:使用 Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
57
src/app.js
57
src/app.js
@@ -121,19 +121,70 @@ class Application {
|
||||
this.app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
if (fs.existsSync(adminSpaPath)) {
|
||||
// 处理不带斜杠的路径,重定向到带斜杠的路径
|
||||
this.app.get('/admin-next', (req, res) => {
|
||||
res.redirect(301, '/admin-next/');
|
||||
});
|
||||
|
||||
// 安全的静态文件服务配置
|
||||
this.app.use('/admin-next/', express.static(adminSpaPath, {
|
||||
maxAge: '1d', // 缓存静态资源1天
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: 'index.html',
|
||||
// 安全选项:禁止目录遍历
|
||||
dotfiles: 'deny', // 拒绝访问点文件
|
||||
redirect: false, // 禁止目录重定向
|
||||
// 自定义错误处理
|
||||
setHeaders: (res, path) => {
|
||||
// 为不同类型的文件设置适当的缓存策略
|
||||
if (path.endsWith('.js') || path.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1年缓存
|
||||
} else if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理SPA路由:所有未匹配的admin-next路径都返回index.html
|
||||
this.app.get('/admin-next/*', (req, res, next) => {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
const requestPath = req.path.replace('/admin-next/', '');
|
||||
if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// 如果是静态资源请求但文件不存在,返回404
|
||||
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// 其他路径返回index.html(SPA路由处理)
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
logger.info('✅ Admin SPA (next) static files mounted at /admin-next/');
|
||||
} else {
|
||||
logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route');
|
||||
}
|
||||
|
||||
// 🛣️ 路由
|
||||
this.app.use('/api', apiRoutes);
|
||||
this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同
|
||||
this.app.use('/admin', adminRoutes);
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes);
|
||||
this.app.use('/apiStats', apiStatsRoutes);
|
||||
this.app.use('/gemini', geminiRoutes);
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes);
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes);
|
||||
|
||||
// 🏠 根路径重定向到API统计页面
|
||||
// 🏠 根路径重定向到新版管理界面
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/apiStats');
|
||||
res.redirect('/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🏥 增强的健康检查端点
|
||||
@@ -321,7 +372,7 @@ class Application {
|
||||
|
||||
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
||||
logger.start(`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`);
|
||||
logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/web`);
|
||||
logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`);
|
||||
logger.info(`🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages`);
|
||||
logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`);
|
||||
logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
const apiKeyService = require('../services/apiKeyService');
|
||||
@@ -8,45 +6,9 @@ const CostCalculator = require('../utils/costCalculator');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
function serveStaticFile(req, res, filename, contentType) {
|
||||
const filePath = path.join(__dirname, '../../web/apiStats', filename);
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error(`❌ API Stats file not found: ${filePath}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// 读取并返回文件内容
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.send(content);
|
||||
|
||||
logger.info(`📄 Served API Stats file: ${filename}`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error serving API Stats file ${filename}:`, error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// 🏠 API Stats 主页面
|
||||
// 🏠 重定向页面请求到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
// 📱 JavaScript 文件
|
||||
router.get('/app.js', (req, res) => {
|
||||
serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
// 🎨 CSS 文件
|
||||
router.get('/style.css', (req, res) => {
|
||||
serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8');
|
||||
res.redirect(301, '/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🔑 获取 API Key 对应的 ID
|
||||
|
||||
@@ -12,52 +12,10 @@ const router = express.Router();
|
||||
// 🏠 服务静态文件
|
||||
router.use('/assets', express.static(path.join(__dirname, '../../web/assets')));
|
||||
|
||||
// 🔒 Web管理界面文件白名单 - 仅允许这些特定文件
|
||||
const ALLOWED_FILES = {
|
||||
'index.html': {
|
||||
path: path.join(__dirname, '../../web/admin/index.html'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
},
|
||||
'app.js': {
|
||||
path: path.join(__dirname, '../../web/admin/app.js'),
|
||||
contentType: 'application/javascript; charset=utf-8'
|
||||
},
|
||||
'style.css': {
|
||||
path: path.join(__dirname, '../../web/admin/style.css'),
|
||||
contentType: 'text/css; charset=utf-8'
|
||||
},
|
||||
};
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
function serveWhitelistedFile(req, res, filename) {
|
||||
const fileConfig = ALLOWED_FILES[filename];
|
||||
|
||||
if (!fileConfig) {
|
||||
logger.security(`🚨 Attempted access to non-whitelisted file: ${filename}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(fileConfig.path)) {
|
||||
logger.error(`❌ Whitelisted file not found: ${fileConfig.path}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// 读取并返回文件内容
|
||||
const content = fs.readFileSync(fileConfig.path, 'utf8');
|
||||
res.setHeader('Content-Type', fileConfig.contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.send(content);
|
||||
|
||||
logger.info(`📄 Served whitelisted file: ${filename}`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error serving file ${filename}:`, error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
// 🌐 页面路由重定向到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(301, '/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🔐 管理员登录
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
@@ -387,22 +345,4 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 🌐 Web管理界面路由 - 使用固定白名单
|
||||
router.get('/', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'index.html');
|
||||
});
|
||||
|
||||
router.get('/app.js', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'app.js');
|
||||
});
|
||||
|
||||
router.get('/style.css', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'style.css');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// 🔑 Gemini OAuth 回调页面
|
||||
|
||||
module.exports = router;
|
||||
@@ -654,7 +654,7 @@ async function refreshAccountToken(accountId) {
|
||||
errorMessage: error.message
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to update account status after refresh error:`, updateError);
|
||||
logger.error('Failed to update account status after refresh error:', updateError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
web/admin-spa/.env.example
Normal file
29
web/admin-spa/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# ========== 基础配置 ==========
|
||||
|
||||
# 应用基础路径
|
||||
# 用于配置路由和资源的基础路径
|
||||
# 开发环境默认:/admin/
|
||||
# 生产环境默认:/admin-next/
|
||||
# 如果使用默认值,可以注释掉此行
|
||||
#VITE_APP_BASE_URL=/admin/
|
||||
|
||||
# 应用标题
|
||||
# 显示在浏览器标签页和页面头部
|
||||
VITE_APP_TITLE=Claude Relay Service - 管理后台
|
||||
|
||||
# ========== 开发环境配置 ==========
|
||||
|
||||
# API 代理目标地址
|
||||
# 开发环境下,所有 /webapi 前缀的请求会被代理到这个地址
|
||||
# 默认值:http://localhost:3000
|
||||
VITE_API_TARGET=http://localhost:3000
|
||||
|
||||
# HTTP 代理配置(可选)
|
||||
# 如果需要通过代理访问后端服务器,请取消注释并配置
|
||||
# 格式:http://proxy-host:port
|
||||
#VITE_HTTP_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# ========== 使用说明 ==========
|
||||
# 1. 复制此文件为 .env.local 进行本地配置
|
||||
# 2. .env.local 文件不会被提交到版本控制
|
||||
# 3. 详细说明请查看 ENV_CONFIG.md
|
||||
22
web/admin-spa/.eslintrc.cjs
Normal file
22
web/admin-spa/.eslintrc.cjs
Normal file
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
browser: true,
|
||||
es2021: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
||||
}
|
||||
}
|
||||
33
web/admin-spa/.gitignore
vendored
Normal file
33
web/admin-spa/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Production build files
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Local env files
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env
|
||||
vite.config.js.timestamp-*.mjs
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
147
web/admin-spa/README.md
Normal file
147
web/admin-spa/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Claude Relay Service 管理后台 SPA
|
||||
|
||||
这是 Claude Relay Service 管理后台的 Vue3 SPA 重构版本。
|
||||
|
||||
## 开发环境要求
|
||||
|
||||
- Node.js >= 16
|
||||
- npm >= 7
|
||||
|
||||
## 安装和运行
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd web/admin-spa
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 开发模式运行
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 开发服务器启动后,会自动在浏览器中打开
|
||||
- 必须访问完整路径:http://localhost:3001/web/admin/
|
||||
- 不要访问 http://localhost:3001/ (会显示404)
|
||||
- 首次访问会自动跳转到登录页面
|
||||
|
||||
### 3. 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物将输出到 `dist` 目录。
|
||||
|
||||
### 4. 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
web/admin-spa/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── api/ # API 接口封装
|
||||
│ ├── assets/ # 资源文件
|
||||
│ ├── components/ # 组件
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面视图
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## 功能模块
|
||||
|
||||
- ✅ 登录认证
|
||||
- ✅ 仪表板(系统统计、使用趋势、模型分布)
|
||||
- 🚧 API Keys 管理
|
||||
- 🚧 账户管理(Claude/Gemini)
|
||||
- 🚧 使用教程
|
||||
- 🚧 系统设置
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3.3.4
|
||||
- Vue Router 4
|
||||
- Pinia(状态管理)
|
||||
- Element Plus 2.4.4
|
||||
- Tailwind CSS
|
||||
- Chart.js 4.4.0
|
||||
- Vite 5
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. 所有 API 请求都通过 `/api` 目录下的模块进行封装
|
||||
2. 状态管理使用 Pinia,存放在 `/stores` 目录
|
||||
3. 组件按功能模块组织在 `/components` 目录下
|
||||
4. 保持与原版页面的功能和样式一致性
|
||||
|
||||
## 代理配置
|
||||
|
||||
如果你的后端服务器需要通过代理访问(例如服务器在国外),可以配置 HTTP 代理:
|
||||
|
||||
### 方法一:使用环境变量文件(推荐)
|
||||
|
||||
创建 `.env.development.local` 文件:
|
||||
|
||||
```bash
|
||||
# 后端服务器地址
|
||||
VITE_API_TARGET=http://74.48.134.98:3000
|
||||
|
||||
# HTTP 代理配置
|
||||
VITE_HTTP_PROXY=http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
### 方法二:使用系统环境变量
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export VITE_HTTP_PROXY=http://127.0.0.1:7890
|
||||
npm run dev
|
||||
|
||||
# Windows
|
||||
set VITE_HTTP_PROXY=http://127.0.0.1:7890
|
||||
npm run dev
|
||||
```
|
||||
|
||||
注意:`.env.development.local` 文件不会被提交到版本控制,适合存放本地特定的配置。
|
||||
|
||||
## 部署
|
||||
|
||||
构建后的文件需要部署到 Claude Relay Service 的 `web/admin/` 路径下。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 访问 localhost:3001 显示 404?
|
||||
A: 这是正常的。应用配置在 `/web/admin/` 路径下,必须访问完整路径:http://localhost:3001/web/admin/
|
||||
|
||||
### Q: 登录时 API 请求失败(500错误)?
|
||||
A:
|
||||
1. **确保主服务运行**:Claude Relay Service 必须运行在 http://localhost:3000
|
||||
2. **检查代理配置**:Vite 会自动代理 `/admin` 和 `/api` 请求到 3000 端口
|
||||
3. **重启开发服务器**:如果修改了配置,需要重启 `npm run dev`
|
||||
4. **测试代理**:运行 `node test-proxy.js` 检查代理是否正常工作
|
||||
|
||||
### Q: 如何处理开发和生产环境的 API 配置?
|
||||
A:
|
||||
- **开发环境**:使用 Vite 代理,自动转发请求到 localhost:3000
|
||||
- **生产环境**:直接使用相对路径 `/admin`,无需配置
|
||||
- 两种环境都使用相同的 API 路径,通过环境变量自动切换
|
||||
|
||||
### Q: 如何部署到生产环境?
|
||||
A:
|
||||
1. 运行 `npm run build` 构建项目
|
||||
2. 将 `dist` 目录内容复制到服务器的 `/web/admin/` 路径
|
||||
3. 确保服务器配置了 SPA 路由回退规则
|
||||
1
web/admin-spa/components/.gitkeep
Normal file
1
web/admin-spa/components/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file keeps the empty directory in git
|
||||
26
web/admin-spa/index.html
Normal file
26
web/admin-spa/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Relay Service - 管理后台</title>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 预连接到CDN域名,加速资源加载 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4760
web/admin-spa/package-lock.json
generated
Normal file
4760
web/admin-spa/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
web/admin-spa/package.json
Normal file
36
web/admin-spa/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "claude-relay-admin-spa",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .js,.vue",
|
||||
"format": "prettier --write \"src/**/*.{js,vue,css}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"unplugin-auto-import": "^0.17.2",
|
||||
"unplugin-element-plus": "^0.8.0",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
web/admin-spa/postcss.config.js
Normal file
6
web/admin-spa/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
31
web/admin-spa/src/App.vue
Normal file
31
web/admin-spa/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ToastNotification from '@/components/common/ToastNotification.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toastRef = ref()
|
||||
const confirmRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
// 检查本地存储的认证状态
|
||||
authStore.checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
352
web/admin-spa/src/assets/styles/components.css
Normal file
352
web/admin-spa/src/assets/styles/components.css
Normal file
@@ -0,0 +1,352 @@
|
||||
/* Glass效果 */
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.tab-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.tab-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* 表单输入 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* 标题渐变 */
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid white;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast通知 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* 版本更新提醒动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 用户菜单下拉框优化 */
|
||||
.user-menu-dropdown {
|
||||
min-width: 240px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* 从原始 style.css 复制的全局样式 */
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
@@ -93,6 +94,101 @@ body::before {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* 表单输入框样式 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
@@ -376,8 +472,6 @@ body::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
@@ -460,3 +554,19 @@ body::before {
|
||||
min-width: 240px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Tab 内容区域样式 */
|
||||
.tab-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
124
web/admin-spa/src/assets/styles/main.css
Normal file
124
web/admin-spa/src/assets/styles/main.css
Normal file
@@ -0,0 +1,124 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './variables.css';
|
||||
@import './components.css';
|
||||
|
||||
/* Font Awesome 图标 */
|
||||
@import '@fortawesome/fontawesome-free/css/all.css';
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 通用transition - 仅应用于特定元素 */
|
||||
body, div, button, input, select, textarea, table, tr, td, th, span, p, h1, h2, h3, h4, h5, h6 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Element Plus 主题覆盖 */
|
||||
.el-button--primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.el-button--primary:hover,
|
||||
.el-button--primary:focus {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* Vue过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.glass, .glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
13
web/admin-spa/src/assets/styles/variables.css
Normal file
13
web/admin-spa/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,13 @@
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
646
web/admin-spa/src/components/accounts/AccountForm.vue
Normal file
646
web/admin-spa/src/components/accounts/AccountForm.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-user-circle text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ isEdit ? '编辑账户' : '添加账户' }}</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤指示器 -->
|
||||
<div v-if="!isEdit && form.addType === 'oauth'" class="flex items-center justify-center mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
|
||||
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
|
||||
1
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">基本信息</span>
|
||||
</div>
|
||||
<div class="w-8 h-0.5 bg-gray-300"></div>
|
||||
<div class="flex items-center">
|
||||
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
|
||||
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
|
||||
2
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">授权认证</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤1: 基本信息和代理设置 -->
|
||||
<div v-if="oauthStep === 1 && !isEdit">
|
||||
<div class="space-y-6">
|
||||
<div v-if="!isEdit">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">平台</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.platform"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.platform"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEdit">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.addType"
|
||||
value="oauth"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">OAuth 授权 (推荐)</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.addType"
|
||||
value="manual"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">手动输入 Access Token</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="账户用途说明..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="shared"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">共享账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="dedicated"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
<input
|
||||
v-model="form.projectId"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="例如:123456789012(纯数字)"
|
||||
>
|
||||
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
|
||||
<div class="text-xs text-yellow-700">
|
||||
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
|
||||
<p>某些 Google 账号(特别是绑定了 Google Cloud 的账号)会被识别为 Workspace 账号,需要提供额外的项目编号。</p>
|
||||
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
|
||||
<p class="font-medium mb-1">如何获取项目编号:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
|
||||
<li>复制<span class="font-semibold text-red-600">项目编号(Project Number)</span>,通常是12位纯数字</li>
|
||||
<li class="text-red-600">⚠️ 注意:不要复制项目ID(Project ID),要复制项目编号!</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud),请留空此字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 Token 字段 -->
|
||||
<div v-if="form.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<i class="fas fa-info text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
|
||||
<p v-if="form.platform === 'claude'" class="text-sm text-blue-800 mb-2">
|
||||
请输入有效的 Claude Access Token。如果您有 Refresh Token,建议也一并填写以支持自动刷新。
|
||||
</p>
|
||||
<p v-else-if="form.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
|
||||
请输入有效的 Gemini Access Token。如果您有 Refresh Token,建议也一并填写以支持自动刷新。
|
||||
</p>
|
||||
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
|
||||
<p class="text-sm text-blue-900 font-medium mb-1">
|
||||
<i class="fas fa-folder-open mr-1"></i>
|
||||
获取 Access Token 的方法:
|
||||
</p>
|
||||
<p v-if="form.platform === 'claude'" class="text-xs text-blue-800">
|
||||
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证,
|
||||
请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||
</p>
|
||||
<p v-else-if="form.platform === 'gemini'" class="text-xs text-blue-800">
|
||||
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code> 文件中的凭证。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600">💡 如果未填写 Refresh Token,Token 过期后需要手动更新。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Access Token *</label>
|
||||
<textarea
|
||||
v-model="form.accessToken"
|
||||
rows="4"
|
||||
required
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="请输入 Access Token..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Refresh Token (可选)</label>
|
||||
<textarea
|
||||
v-model="form.refreshToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="请输入 Refresh Token..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理设置 -->
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
v-if="form.addType === 'oauth'"
|
||||
type="button"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceed"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="createAccount"
|
||||
:disabled="loading || !canCreate"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: OAuth授权 -->
|
||||
<OAuthFlow
|
||||
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
||||
:platform="form.platform"
|
||||
:proxy="form.proxy"
|
||||
@success="handleOAuthSuccess"
|
||||
@back="oauthStep = 1"
|
||||
/>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="isEdit" class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="账户用途说明..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="shared"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">共享账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="dedicated"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
<input
|
||||
v-model="form.projectId"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="例如:123456789012(纯数字)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Google Cloud/Workspace 账号可能需要提供项目编号
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Token 更新 -->
|
||||
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<i class="fas fa-key text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-semibold text-amber-900 mb-2">更新 Token</h5>
|
||||
<p class="text-sm text-amber-800 mb-2">可以更新 Access Token 和 Refresh Token。为了安全起见,不会显示当前的 Token 值。</p>
|
||||
<p class="text-xs text-amber-600">💡 留空表示不更新该字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Access Token</label>
|
||||
<textarea
|
||||
v-model="form.accessToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="留空表示不更新..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Refresh Token</label>
|
||||
<textarea
|
||||
v-model="form.refreshToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="留空表示不更新..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理设置 -->
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="updateAccount"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认弹窗 -->
|
||||
<ConfirmModal
|
||||
:show="showConfirmModal"
|
||||
:title="confirmOptions.title"
|
||||
:message="confirmOptions.message"
|
||||
:confirm-text="confirmOptions.confirmText"
|
||||
:cancel-text="confirmOptions.cancelText"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const show = ref(true)
|
||||
|
||||
// OAuth步骤
|
||||
const oauthStep = ref(1)
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
addType: 'oauth',
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
projectId: props.account?.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
proxy: props.account?.proxy || {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 计算是否可以进入下一步
|
||||
const canProceed = computed(() => {
|
||||
return form.value.name && form.value.platform
|
||||
})
|
||||
|
||||
// 计算是否可以创建
|
||||
const canCreate = computed(() => {
|
||||
if (form.value.addType === 'manual') {
|
||||
return form.value.name && form.value.accessToken
|
||||
}
|
||||
return form.value.name
|
||||
})
|
||||
|
||||
// 下一步
|
||||
const nextStep = async () => {
|
||||
if (!canProceed.value) {
|
||||
if (!form.value.name || form.value.name.trim() === '') {
|
||||
showToast('请填写账户名称', 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续',
|
||||
'返回填写'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oauthStep.value = 2
|
||||
}
|
||||
|
||||
// 处理OAuth成功
|
||||
const handleOAuthSuccess = async (tokenInfo) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
accessToken: tokenInfo.access_token,
|
||||
refreshToken: tokenInfo.refresh_token,
|
||||
scopes: tokenInfo.scopes || [],
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
if (form.value.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
} else {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
}
|
||||
|
||||
showToast('账户创建成功', 'success')
|
||||
emit('success', result)
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建账户(手动模式)
|
||||
const createAccount = async () => {
|
||||
if (!canCreate.value) {
|
||||
if (!form.value.name || form.value.name.trim() === '') {
|
||||
showToast('请填写账户名称', 'error')
|
||||
} else if (!form.value.accessToken || form.value.accessToken.trim() === '') {
|
||||
showToast('请填写 Access Token', 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
accessToken: form.value.accessToken,
|
||||
refreshToken: form.value.refreshToken || undefined,
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
if (form.value.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
} else {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
}
|
||||
|
||||
showToast('账户创建成功', 'success')
|
||||
emit('success', result)
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
const updateAccount = async () => {
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续保存',
|
||||
'返回填写'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
// 只有非空时才更新token
|
||||
if (form.value.accessToken) {
|
||||
data.accessToken = form.value.accessToken
|
||||
}
|
||||
if (form.value.refreshToken) {
|
||||
data.refreshToken = form.value.refreshToken
|
||||
}
|
||||
|
||||
if (props.account.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
if (props.account.platform === 'claude') {
|
||||
await accountsStore.updateClaudeAccount(props.account.id, data)
|
||||
} else {
|
||||
await accountsStore.updateGeminiAccount(props.account.id, data)
|
||||
}
|
||||
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听账户变化,更新表单
|
||||
watch(() => props.account, (newAccount) => {
|
||||
if (newAccount) {
|
||||
form.value = {
|
||||
platform: newAccount.platform,
|
||||
addType: 'oauth',
|
||||
name: newAccount.name,
|
||||
description: newAccount.description || '',
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
projectId: newAccount.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
proxy: newAccount.proxy || {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
377
web/admin-spa/src/components/accounts/OAuthFlow.vue
Normal file
377
web/admin-spa/src/components/accounts/OAuthFlow.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Claude OAuth流程 -->
|
||||
<div v-if="platform === 'claude'">
|
||||
<div class="bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-link text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-blue-900 mb-3">Claude 账户授权</h4>
|
||||
<p class="text-sm text-blue-800 mb-4">
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@click="generateAuthUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2"></i>
|
||||
<div v-else class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
@click="copyAuthUrl"
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="regenerateAuthUrl"
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 访问链接并授权 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">在浏览器中打开链接并完成授权</p>
|
||||
<p class="text-sm text-blue-700 mb-2">
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
|
||||
<p class="text-xs text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<strong>注意:</strong>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">输入 Authorization Code</p>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
授权完成后,页面会显示一个 <strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-blue-500 mr-2"></i>Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
></textarea>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini OAuth流程 -->
|
||||
<div v-else-if="platform === 'gemini'">
|
||||
<div class="bg-green-50 p-6 rounded-lg border border-green-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-robot text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-green-900 mb-3">Gemini 账户授权</h4>
|
||||
<p class="text-sm text-green-800 mb-4">
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@click="generateAuthUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2"></i>
|
||||
<div v-else class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
@click="copyAuthUrl"
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="regenerateAuthUrl"
|
||||
class="text-xs text-green-600 hover:text-green-700"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 操作说明 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">在浏览器中打开链接并完成授权</p>
|
||||
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside mb-3">
|
||||
<li>点击上方的授权链接,在新页面中完成Google账号登录</li>
|
||||
<li>点击“登录”按钮后可能会加载很慢(这是正常的)</li>
|
||||
<li>如果超过1分钟还在加载,请按 F5 刷新页面</li>
|
||||
<li>授权完成后会跳转到 http://localhost:45462 (可能显示无法访问)</li>
|
||||
</ol>
|
||||
<div class="bg-green-100 p-3 rounded border border-green-300">
|
||||
<p class="text-xs text-green-700">
|
||||
<i class="fas fa-lightbulb mr-1"></i>
|
||||
<strong>提示:</strong>如果页面一直无法跳转,可以打开浏览器开发者工具(F12),F5刷新一下授权页再点击页面的登录按钮,在“网络”标签中找到以 localhost:45462 开头的请求,复制其完整URL。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">复制oauth后的链接</p>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
复制浏览器地址栏的完整链接并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-green-500 mr-2"></i>复制oauth后的链接
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴以 http://localhost:45462 开头的完整链接..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1"></i>
|
||||
支持粘贴完整链接,系统会自动提取授权码
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1"></i>
|
||||
也可以直接粘贴授权码(code参数的值)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('back')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="exchangeCode"
|
||||
:disabled="!canExchange || exchanging"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="exchanging" class="loading-spinner mr-2"></div>
|
||||
{{ exchanging ? '验证中...' : '完成授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
|
||||
const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
proxy: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'back'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const exchanging = ref(false)
|
||||
const authUrl = ref('')
|
||||
const authCode = ref('')
|
||||
const copied = ref(false)
|
||||
|
||||
// 计算是否可以交换code
|
||||
const canExchange = computed(() => {
|
||||
return authUrl.value && authCode.value.trim()
|
||||
})
|
||||
|
||||
// 生成授权URL
|
||||
const generateAuthUrl = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const proxyConfig = props.proxy?.enabled ? {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: props.proxy.port,
|
||||
username: props.proxy.username,
|
||||
password: props.proxy.password
|
||||
} : null
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
authUrl.value = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||
} else if (props.platform === 'gemini') {
|
||||
authUrl.value = await accountsStore.generateGeminiAuthUrl(proxyConfig)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '生成授权链接失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成授权URL
|
||||
const regenerateAuthUrl = () => {
|
||||
authUrl.value = ''
|
||||
authCode.value = ''
|
||||
generateAuthUrl()
|
||||
}
|
||||
|
||||
// 复制授权URL
|
||||
const copyAuthUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl.value)
|
||||
copied.value = true
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
// 降级方案
|
||||
const input = document.createElement('input')
|
||||
input.value = authUrl.value
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 交换授权码
|
||||
const exchangeCode = async () => {
|
||||
if (!canExchange.value) return
|
||||
|
||||
exchanging.value = true
|
||||
try {
|
||||
const data = {
|
||||
code: authCode.value.trim()
|
||||
}
|
||||
|
||||
if (props.proxy?.enabled) {
|
||||
data.proxy = {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: props.proxy.port,
|
||||
username: props.proxy.username,
|
||||
password: props.proxy.password
|
||||
}
|
||||
}
|
||||
|
||||
let tokenInfo
|
||||
if (props.platform === 'claude') {
|
||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||
} else if (props.platform === 'gemini') {
|
||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||
}
|
||||
|
||||
emit('success', tokenInfo)
|
||||
} catch (error) {
|
||||
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
166
web/admin-spa/src/components/accounts/ProxyConfig.vue
Normal file
166
web/admin-spa/src/components/accounts/ProxyConfig.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="proxy.enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700">启用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.enabled" class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-server text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700">
|
||||
配置代理以访问受限的网络资源。支持 SOCKS5 和 HTTP 代理。
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
请确保代理服务器稳定可用,否则会影响账户的正常使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">代理类型</label>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">主机地址</label>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
type="text"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">端口</label>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
type="number"
|
||||
placeholder="例如: 1080"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showAuth"
|
||||
id="proxyAuth"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="proxyAuth" class="ml-2 text-sm text-gray-700 cursor-pointer">
|
||||
需要身份验证
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
type="text"
|
||||
placeholder="代理用户名"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="代理密码"
|
||||
class="form-input w-full pr-10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<p class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<strong>提示:</strong>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 内部代理数据
|
||||
const proxy = ref({ ...props.modelValue })
|
||||
|
||||
// UI状态
|
||||
const showAuth = ref(!!(proxy.value.username || proxy.value.password))
|
||||
const showPassword = ref(false)
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
proxy.value = { ...newVal }
|
||||
showAuth.value = !!(newVal.username || newVal.password)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听proxy变化,更新父组件
|
||||
watch(proxy, (newVal) => {
|
||||
// 如果不需要认证,清空用户名密码
|
||||
if (!showAuth.value) {
|
||||
newVal.username = ''
|
||||
newVal.password = ''
|
||||
}
|
||||
emit('update:modelValue', { ...newVal })
|
||||
}, { deep: true })
|
||||
|
||||
// 监听认证开关
|
||||
watch(showAuth, (newVal) => {
|
||||
if (!newVal) {
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
534
web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
Normal file
534
web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-key text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">创建新的 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为您的 API Key 取一个名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
|
||||
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
type="number"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="bg-blue-100 rounded-lg p-3 mt-3">
|
||||
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p><strong>示例1:</strong> 时间窗口=60,请求次数限制=1000</p>
|
||||
<p class="ml-4">→ 每60分钟内最多1000次请求</p>
|
||||
<p class="mt-2"><strong>示例2:</strong> 时间窗口=1,Token限制=10000</p>
|
||||
<p class="ml-4">→ 每分钟最多消耗10,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">备注 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="描述此 API Key 的用途..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">有效期限</label>
|
||||
<select
|
||||
v-model="form.expireDuration"
|
||||
@change="updateExpireAt"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="">永不过期</option>
|
||||
<option value="1d">1 天</option>
|
||||
<option value="7d">7 天</option>
|
||||
<option value="30d">30 天</option>
|
||||
<option value="90d">90 天</option>
|
||||
<option value="180d">180 天</option>
|
||||
<option value="365d">365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
</select>
|
||||
<div v-if="form.expireDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
type="datetime-local"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
@change="updateCustomExpireAt"
|
||||
>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="text-xs text-gray-500 mt-2">
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="all"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.claude.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableModelRestriction"
|
||||
id="enableModelRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="enableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
type="text"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
class="form-input flex-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型,例如:claude-opus-4-20250514</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制 -->
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableClientRestriction"
|
||||
id="enableClientRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="enableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
|
||||
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`client_${client.id}`"
|
||||
:value="client.id"
|
||||
v-model="form.allowedClients"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
|
||||
>
|
||||
<label :for="`client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
|
||||
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !form.name"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-plus mr-2"></i>
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
type: Object,
|
||||
default: () => ({ claude: [], gemini: [] })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
expireDuration: '',
|
||||
customExpireDate: '',
|
||||
expiresAt: null,
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
})
|
||||
|
||||
// 加载支持的客户端
|
||||
onMounted(async () => {
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
})
|
||||
|
||||
// 计算最小日期时间
|
||||
const minDateTime = computed(() => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 1)
|
||||
return now.toISOString().slice(0, 16)
|
||||
})
|
||||
|
||||
// 更新过期时间
|
||||
const updateExpireAt = () => {
|
||||
if (!form.expireDuration) {
|
||||
form.expiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.expireDuration === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const duration = form.expireDuration
|
||||
const match = duration.match(/(\d+)([dhmy])/)
|
||||
|
||||
if (match) {
|
||||
const [, value, unit] = match
|
||||
const num = parseInt(value)
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
now.setDate(now.getDate() + num)
|
||||
break
|
||||
case 'h':
|
||||
now.setHours(now.getHours() + num)
|
||||
break
|
||||
case 'm':
|
||||
now.setMonth(now.getMonth() + num)
|
||||
break
|
||||
case 'y':
|
||||
now.setFullYear(now.getFullYear() + num)
|
||||
break
|
||||
}
|
||||
|
||||
form.expiresAt = now.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新自定义过期时间
|
||||
const updateCustomExpireAt = () => {
|
||||
if (form.customExpireDate) {
|
||||
form.expiresAt = new Date(form.customExpireDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
form.restrictedModels.push(form.modelInput)
|
||||
form.modelInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除限制的模型
|
||||
const removeRestrictedModel = (index) => {
|
||||
form.restrictedModels.splice(index, 1)
|
||||
}
|
||||
|
||||
// 创建 API Key
|
||||
const createApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
|
||||
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : null,
|
||||
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : null,
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
expiresAt: form.expiresAt || undefined,
|
||||
permissions: form.permissions,
|
||||
claudeAccountId: form.claudeAccountId || undefined,
|
||||
geminiAccountId: form.geminiAccountId || undefined
|
||||
}
|
||||
|
||||
// 模型限制
|
||||
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
|
||||
data.restrictedModels = form.restrictedModels
|
||||
}
|
||||
|
||||
// 客户端限制
|
||||
if (form.enableClientRestriction && form.allowedClients.length > 0) {
|
||||
data.allowedClients = form.allowedClients
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/api-keys', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 创建成功', 'success')
|
||||
emit('success', result.apiKey)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '创建失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
447
web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
Normal file
447
web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-edit text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">编辑 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
:value="form.name"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
|
||||
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口),0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="bg-blue-100 rounded-lg p-3 mt-3">
|
||||
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p><strong>示例1:</strong> 时间窗口=60,请求次数限制=100</p>
|
||||
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
|
||||
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10,Token限制=50000</p>
|
||||
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="all"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.claude"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.gemini"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableModelRestriction"
|
||||
id="editEnableModelRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="editEnableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
type="text"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
class="form-input flex-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型,例如:claude-opus-4-20250514</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制 -->
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableClientRestriction"
|
||||
id="editEnableClientRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="editEnableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
|
||||
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`edit_client_${client.id}`"
|
||||
:value="client.id"
|
||||
v-model="form.allowedClients"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
|
||||
>
|
||||
<label :for="`edit_client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
|
||||
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ loading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
accounts: {
|
||||
type: Object,
|
||||
default: () => ({ claude: [], gemini: [] })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
})
|
||||
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
form.restrictedModels.push(form.modelInput)
|
||||
form.modelInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除限制的模型
|
||||
const removeRestrictedModel = (index) => {
|
||||
form.restrictedModels.splice(index, 1)
|
||||
}
|
||||
|
||||
// 更新 API Key
|
||||
const updateApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
|
||||
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : 0,
|
||||
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0,
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
permissions: form.permissions,
|
||||
claudeAccountId: form.claudeAccountId || null,
|
||||
geminiAccountId: form.geminiAccountId || null
|
||||
}
|
||||
|
||||
// 模型限制
|
||||
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
|
||||
data.restrictedModels = form.restrictedModels
|
||||
} else {
|
||||
data.restrictedModels = []
|
||||
}
|
||||
|
||||
// 客户端限制
|
||||
if (form.enableClientRestriction && form.allowedClients.length > 0) {
|
||||
data.allowedClients = form.allowedClients
|
||||
} else {
|
||||
data.allowedClients = []
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '更新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化表单数据
|
||||
onMounted(async () => {
|
||||
// 加载支持的客户端
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
form.tokenLimit = props.apiKey.tokenLimit || ''
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
|
||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
form.enableModelRestriction = form.restrictedModels.length > 0
|
||||
form.enableClientRestriction = form.allowedClients.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
155
web/admin-spa/src/components/apikeys/NewApiKeyModal.vue
Normal file
155
web/admin-spa/src/components/apikeys/NewApiKeyModal.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-check text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-shield-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-1">请妥善保管您的 API Key</h4>
|
||||
<p class="text-sm text-gray-600">API Key 只会显示一次,关闭此窗口后将无法再次查看完整密钥。请立即复制并保存到安全的地方。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key 信息 -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">名称</label>
|
||||
<p class="text-gray-900">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述</label>
|
||||
<p class="text-gray-600 text-sm">{{ apiKey.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="showFullKey ? 'text' : 'password'"
|
||||
:value="apiKey.key"
|
||||
readonly
|
||||
class="form-input w-full pr-24 font-mono text-sm bg-gray-50"
|
||||
>
|
||||
<div class="absolute right-1 top-1 flex gap-1">
|
||||
<button
|
||||
@click="toggleKeyVisibility"
|
||||
type="button"
|
||||
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors"
|
||||
:title="showFullKey ? '隐藏' : '显示'"
|
||||
>
|
||||
<i :class="showFullKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="copyApiKey"
|
||||
type="button"
|
||||
class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg text-sm transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-500"></i>使用说明
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 space-y-2">
|
||||
<p>1. 在 HTTP 请求头中添加:</p>
|
||||
<code class="block bg-white rounded px-3 py-2 text-xs">Authorization: Bearer {{ apiKey.key }}</code>
|
||||
|
||||
<p class="pt-2">2. 请求示例:</p>
|
||||
<pre class="bg-white rounded px-3 py-2 text-xs overflow-x-auto">curl -X POST {{ currentBaseUrl }}v1/messages \
|
||||
-H "Authorization: Bearer {{ apiKey.key }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "claude-3-opus-20240229", "messages": [...]}'</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="btn btn-primary px-6 py-2.5"
|
||||
>
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const showFullKey = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
// 计算基础 URL
|
||||
const currentBaseUrl = computed(() => {
|
||||
return `${window.location.protocol}//${window.location.host}/api/`
|
||||
})
|
||||
|
||||
// 切换密钥可见性
|
||||
const toggleKeyVisibility = () => {
|
||||
showFullKey.value = !showFullKey.value
|
||||
}
|
||||
|
||||
// 复制 API Key
|
||||
const copyApiKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.apiKey.key)
|
||||
copied.value = true
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
|
||||
// 3秒后重置复制状态
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
224
web/admin-spa/src/components/apikeys/RenewApiKeyModal.vue
Normal file
224
web/admin-spa/src/components/apikeys/RenewApiKeyModal.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-clock text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-info text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-1">API Key 信息</h4>
|
||||
<p class="text-sm text-gray-700">{{ apiKey.name }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
当前过期时间:{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
@change="updateRenewExpireAt"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="7d">延长 7 天</option>
|
||||
<option value="30d">延长 30 天</option>
|
||||
<option value="90d">延长 90 天</option>
|
||||
<option value="180d">延长 180 天</option>
|
||||
<option value="365d">延长 365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
<option value="permanent">设为永不过期</option>
|
||||
</select>
|
||||
<div v-if="form.renewDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
type="datetime-local"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
@change="updateCustomRenewExpireAt"
|
||||
>
|
||||
</div>
|
||||
<p v-if="form.newExpiresAt" class="text-xs text-gray-500 mt-2">
|
||||
新的过期时间:{{ formatExpireDate(form.newExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="renewApiKey"
|
||||
:disabled="loading || !form.renewDuration"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-clock mr-2"></i>
|
||||
{{ loading ? '续期中...' : '确认续期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
renewDuration: '30d',
|
||||
customExpireDate: '',
|
||||
newExpiresAt: null
|
||||
})
|
||||
|
||||
// 计算最小日期时间
|
||||
const minDateTime = computed(() => {
|
||||
const now = new Date()
|
||||
// 如果有当前过期时间且未过期,从当前过期时间开始
|
||||
if (props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > now) {
|
||||
return new Date(props.apiKey.expiresAt).toISOString().slice(0, 16)
|
||||
}
|
||||
// 否则从现在开始
|
||||
now.setMinutes(now.getMinutes() + 1)
|
||||
return now.toISOString().slice(0, 16)
|
||||
})
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新续期后的过期时间
|
||||
const updateRenewExpireAt = () => {
|
||||
if (!form.renewDuration) {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.renewDuration === 'permanent') {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.renewDuration === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
const baseDate = props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
|
||||
? new Date(props.apiKey.expiresAt)
|
||||
: new Date()
|
||||
|
||||
const duration = form.renewDuration
|
||||
const match = duration.match(/(\d+)([dhmy])/)
|
||||
|
||||
if (match) {
|
||||
const [, value, unit] = match
|
||||
const num = parseInt(value)
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
baseDate.setDate(baseDate.getDate() + num)
|
||||
break
|
||||
case 'h':
|
||||
baseDate.setHours(baseDate.getHours() + num)
|
||||
break
|
||||
case 'm':
|
||||
baseDate.setMonth(baseDate.getMonth() + num)
|
||||
break
|
||||
case 'y':
|
||||
baseDate.setFullYear(baseDate.getFullYear() + num)
|
||||
break
|
||||
}
|
||||
|
||||
form.newExpiresAt = baseDate.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新自定义续期时间
|
||||
const updateCustomRenewExpireAt = () => {
|
||||
if (form.customExpireDate) {
|
||||
form.newExpiresAt = new Date(form.customExpireDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 续期 API Key
|
||||
const renewApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const data = {
|
||||
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}/renew`, data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 续期成功', 'success')
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '续期失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('续期失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
updateRenewExpireAt()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
269
web/admin-spa/src/components/apistats/ApiKeyInput.vue
Normal file
269
web/admin-spa/src/components/apistats/ApiKeyInput.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<!-- 标题区域 -->
|
||||
<div class="wide-card-title text-center mb-6">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<i class="fas fa-chart-line mr-3"></i>
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
|
||||
<!-- API Key 输入 -->
|
||||
<div class="lg:col-span-3">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
输入您的 API Key
|
||||
</label>
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
class="wide-card-input w-full"
|
||||
@keyup.enter="queryStats"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="hidden lg:block text-sm font-medium mb-2 text-gray-700">
|
||||
|
||||
</label>
|
||||
<button
|
||||
@click="queryStats"
|
||||
:disabled="loading || !apiKey.trim()"
|
||||
class="btn btn-primary btn-query w-full h-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner"></i>
|
||||
<i v-else class="fas fa-search"></i>
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { apiKey, loading } = storeToRefs(apiStatsStore)
|
||||
const { queryStats } = apiStatsStore
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 宽卡片样式 */
|
||||
.api-input-wide-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.api-input-wide-card:hover {
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(0, 0, 0, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.wide-card-title h2 {
|
||||
color: #1f2937;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
color: #4b5563;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.wide-card-title .fas.fa-chart-line {
|
||||
color: #3b82f6;
|
||||
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.api-input-grid {
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.wide-card-input {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
color: #1f2937;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wide-card-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.wide-card-input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96, 165, 250, 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* 查询按钮特定样式 */
|
||||
.btn-query {
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 安全提示样式 */
|
||||
.security-notice {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.security-notice:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.security-notice .fas.fa-shield-alt {
|
||||
color: #10b981;
|
||||
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.api-input-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wide-card-input {
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-query {
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wide-card-input {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-query {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
web/admin-spa/src/components/apistats/LimitConfig.vue
Normal file
173
web/admin-spa/src/components/apistats/LimitConfig.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 限制配置 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
限制配置
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">Token 限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">并发限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">速率限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
|
||||
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
|
||||
: '无限制' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">每日费用限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">模型限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有模型
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">客户端限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有客户端
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细限制信息 -->
|
||||
<div v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
|
||||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
|
||||
class="card p-6 mt-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-list-alt mr-3 text-amber-500"></i>
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 模型限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-amber-800 mb-3 flex items-center">
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="model in statsData.restrictions.restrictedModels"
|
||||
:key="model"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-amber-200">
|
||||
<i class="fas fa-ban mr-2 text-red-500"></i>
|
||||
<span class="text-gray-800">{{ model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-amber-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-blue-800 mb-3 flex items-center">
|
||||
<i class="fas fa-desktop mr-2"></i>
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in statsData.restrictions.allowedClients"
|
||||
:key="client"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-blue-200">
|
||||
<i class="fas fa-check mr-2 text-green-500"></i>
|
||||
<span class="text-gray-800">{{ client }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
173
web/admin-spa/src/components/apistats/ModelUsageStats.vue
Normal file
173
web/admin-spa/src/components/apistats/ModelUsageStats.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold flex items-center text-gray-900">
|
||||
<i class="fas fa-robot mr-3 text-indigo-500"></i>
|
||||
模型使用统计 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计加载状态 -->
|
||||
<div v-if="modelStatsLoading" class="text-center py-8">
|
||||
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600"></i>
|
||||
<p class="text-gray-600">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
<div v-else-if="modelStats.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(model, index) in modelStats"
|
||||
:key="index"
|
||||
class="model-usage-item"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 class="font-bold text-lg text-gray-900">{{ model.model }}</h4>
|
||||
<p class="text-gray-600 text-sm">{{ model.requests }} 次请求</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-green-600">{{ model.formatted?.total || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输入 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.inputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输出 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.outputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存创建</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheCreateTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存读取</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheReadTokens) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无模型数据 -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-chart-pie text-3xl mb-3"></i>
|
||||
<p>暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 模型使用项样式 */
|
||||
.model-usage-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-usage-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.model-usage-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
web/admin-spa/src/components/apistats/StatsOverview.vue
Normal file
255
web/admin-spa/src/components/apistats/StatsOverview.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- API Key 基本信息 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
|
||||
API Key 信息
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">名称</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">状态</span>
|
||||
<span :class="statsData.isActive ? 'text-green-600' : 'text-red-600'" class="font-medium">
|
||||
<i :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">权限</span>
|
||||
<span class="font-medium text-gray-900">{{ formatPermissions(statsData.permissions) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">创建时间</span>
|
||||
<span class="font-medium text-gray-900">{{ formatDate(statsData.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">过期时间</span>
|
||||
<div v-if="statsData.expiresAt">
|
||||
<div v-if="isApiKeyExpired(statsData.expiresAt)" class="text-red-600 font-medium">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
已过期
|
||||
</div>
|
||||
<div v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" class="text-orange-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-900 font-medium">
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 font-medium">
|
||||
<i class="fas fa-infinity mr-1"></i>
|
||||
永不过期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用统计概览 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-chart-bar mr-3 text-green-500"></i>
|
||||
使用统计概览 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '无'
|
||||
|
||||
try {
|
||||
const date = dayjs(dateString)
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} catch (error) {
|
||||
return '格式错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 API Key 是否已过期
|
||||
const isApiKeyExpired = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
// 检查 API Key 是否即将过期(7天内)
|
||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
const expireDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpire > 0 && daysUntilExpire <= 7
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化权限
|
||||
const formatPermissions = (permissions) => {
|
||||
const permissionMap = {
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
'all': '全部模型'
|
||||
}
|
||||
|
||||
return permissionMap[permissions] || permissions || '未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card .text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card .text-3xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card .text-sm {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
web/admin-spa/src/components/apistats/TokenDistribution.vue
Normal file
102
web/admin-spa/src/components/apistats/TokenDistribution.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-coins mr-3 text-yellow-500"></i>
|
||||
Token 使用分布 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-right mr-2 text-green-500"></i>
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-left mr-2 text-blue-500"></i>
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-save mr-2 text-purple-500"></i>
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-download mr-2 text-orange-500"></i>
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex justify-between items-center font-bold text-gray-900">
|
||||
<span>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
|
||||
<span class="text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
193
web/admin-spa/src/components/common/ConfirmDialog.vue
Normal file
193
web/admin-spa/src/components/common/ConfirmDialog.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-white text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<div class="text-gray-600 leading-relaxed whitespace-pre-line">{{ message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="handleCancel"
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
class="btn btn-danger px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isProcessing }"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div v-if="isProcessing" class="loading-spinner mr-2"></div>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 状态
|
||||
const isVisible = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
const confirmText = ref('确认')
|
||||
const cancelText = ref('取消')
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancelTextParam = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
message.value = messageText
|
||||
confirmText.value = confirmTextParam
|
||||
cancelText.value = cancelTextParam
|
||||
isVisible.value = true
|
||||
isProcessing.value = false
|
||||
resolvePromise = resolve
|
||||
})
|
||||
}
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
// 延迟一点时间以显示loading状态
|
||||
setTimeout(() => {
|
||||
isVisible.value = false
|
||||
isProcessing.value = false
|
||||
if (resolvePromise) {
|
||||
resolvePromise(true)
|
||||
resolvePromise = null
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isVisible.value = false
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false)
|
||||
resolvePromise = null
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event) => {
|
||||
if (!isVisible.value) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel()
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
|
||||
handleConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局方法注册
|
||||
onMounted(() => {
|
||||
window.showConfirm = showConfirm
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (window.showConfirm === showConfirm) {
|
||||
delete window.showConfirm
|
||||
}
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 暴露方法供组件使用
|
||||
defineExpose({
|
||||
showConfirm
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e5e7eb;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-4 h-4 border-2 border-gray-300 border-t-white rounded-full animate-spin;
|
||||
}
|
||||
|
||||
/* Modal transitions */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-content,
|
||||
.modal-leave-active .modal-content {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-content,
|
||||
.modal-leave-to .modal-content {
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.modal-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
59
web/admin-spa/src/components/common/ConfirmModal.vue
Normal file
59
web/admin-spa/src/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-exclamation text-white text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '继续'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
81
web/admin-spa/src/components/common/LogoTitle.vue
Normal file
81
web/admin-spa/src/components/common/LogoTitle.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Logo区域 -->
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
|
||||
<template v-if="!loading">
|
||||
<img v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
alt="Logo"
|
||||
class="w-8 h-8 object-contain"
|
||||
@error="handleLogoError">
|
||||
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
|
||||
</template>
|
||||
<div v-else class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex flex-col justify-center min-h-[48px]">
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="!loading && title">
|
||||
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">{{ title }}</h1>
|
||||
</template>
|
||||
<div v-else-if="loading" class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-gray-600 text-sm leading-tight mt-0.5">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
logoSrc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
titleClass: {
|
||||
type: String,
|
||||
default: 'text-gray-900'
|
||||
}
|
||||
})
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleLogoError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 骨架屏动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.header-title {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
56
web/admin-spa/src/components/common/StatCard.vue
Normal file
56
web/admin-spa/src/components/common/StatCard.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ value }}</p>
|
||||
<p v-if="subtitle" class="text-sm text-gray-500 mt-2">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div :class="['stat-icon', iconBgClass]">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const colorMap = {
|
||||
primary: 'bg-gradient-to-br from-blue-500 to-purple-500',
|
||||
success: 'bg-gradient-to-br from-green-500 to-emerald-500',
|
||||
warning: 'bg-gradient-to-br from-yellow-500 to-orange-500',
|
||||
danger: 'bg-gradient-to-br from-red-500 to-pink-500',
|
||||
info: 'bg-gradient-to-br from-cyan-500 to-blue-500'
|
||||
}
|
||||
return colorMap[props.iconColor] || colorMap.primary
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
|
||||
</style>
|
||||
373
web/admin-spa/src/components/common/ToastNotification.vue
Normal file
373
web/admin-spa/src/components/common/ToastNotification.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'toast',
|
||||
`toast-${toast.type}`,
|
||||
toast.isVisible ? 'toast-show' : 'toast-hide'
|
||||
]"
|
||||
@click="removeToast(toast.id)"
|
||||
>
|
||||
<div class="toast-content">
|
||||
<div class="toast-icon">
|
||||
<i :class="getIconClass(toast.type)"></i>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div v-if="toast.title" class="toast-title">{{ toast.title }}</div>
|
||||
<div class="toast-message">{{ toast.message }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="toast-close"
|
||||
@click.stop="removeToast(toast.id)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="toast.duration > 0"
|
||||
class="toast-progress"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 状态
|
||||
const toasts = ref([])
|
||||
let toastIdCounter = 0
|
||||
|
||||
// 获取图标类名
|
||||
const getIconClass = (type) => {
|
||||
const iconMap = {
|
||||
success: 'fas fa-check-circle',
|
||||
error: 'fas fa-exclamation-circle',
|
||||
warning: 'fas fa-exclamation-triangle',
|
||||
info: 'fas fa-info-circle'
|
||||
}
|
||||
return iconMap[type] || iconMap.info
|
||||
}
|
||||
|
||||
// 添加Toast
|
||||
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
const id = ++toastIdCounter
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
title,
|
||||
duration,
|
||||
isVisible: false
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// 下一帧显示动画
|
||||
setTimeout(() => {
|
||||
toast.isVisible = true
|
||||
}, 10)
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// 移除Toast
|
||||
const removeToast = (id) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index > -1) {
|
||||
const toast = toasts.value[index]
|
||||
toast.isVisible = false
|
||||
|
||||
// 等待动画完成后移除
|
||||
setTimeout(() => {
|
||||
const currentIndex = toasts.value.findIndex(t => t.id === id)
|
||||
if (currentIndex > -1) {
|
||||
toasts.value.splice(currentIndex, 1)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有Toast
|
||||
const clearAllToasts = () => {
|
||||
toasts.value.forEach(toast => {
|
||||
toast.isVisible = false
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
toasts.value.length = 0
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 暴露方法给全局使用
|
||||
const showToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
return addToast(message, type, title, duration)
|
||||
}
|
||||
|
||||
// 全局方法注册
|
||||
onMounted(() => {
|
||||
// 将方法挂载到全局
|
||||
window.showToast = showToast
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理全局方法
|
||||
if (window.showToast === showToast) {
|
||||
delete window.showToast
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法供组件使用
|
||||
defineExpose({
|
||||
showToast,
|
||||
removeToast,
|
||||
clearAllToasts
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toast-show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-hide {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: toast-progress linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Success Toast */
|
||||
.toast-success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #10b981;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
.toast-success .toast-title {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.toast-success .toast-message {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.toast-success .toast-progress {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
/* Error Toast */
|
||||
.toast-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #ef4444;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.toast-error .toast-title {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.toast-error .toast-message {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.toast-error .toast-progress {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* Warning Toast */
|
||||
.toast-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.toast-warning .toast-title {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.toast-warning .toast-message {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.toast-warning .toast-progress {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info Toast */
|
||||
.toast-info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3b82f6;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.toast-info .toast-title {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.toast-info .toast-message {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.toast-info .toast-progress {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast List Transitions */
|
||||
.toast-list-enter-active,
|
||||
.toast-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
134
web/admin-spa/src/components/dashboard/ModelDistribution.vue
Normal file
134
web/admin-spa/src/components/dashboard/ModelDistribution.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<i class="fas fa-robot mr-2 text-purple-500"></i>
|
||||
模型使用分布
|
||||
</h2>
|
||||
|
||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||
<el-radio-button label="daily">今日</el-radio-button>
|
||||
<el-radio-button label="total">累计</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardStore.dashboardModelStats.length === 0" class="text-center py-12 text-gray-500">
|
||||
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30"></i>
|
||||
<p>暂无模型使用数据</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 饼图 -->
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="(stat, index) in sortedStats" :key="stat.model"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-4 h-4 rounded" :style="`background-color: ${getColor(index)}`"></div>
|
||||
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
|
||||
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
|
||||
const modelPeriod = ref('daily')
|
||||
|
||||
const sortedStats = computed(() => {
|
||||
return [...dashboardStore.dashboardModelStats].sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const getColor = (index) => {
|
||||
const { colorSchemes } = useChartConfig()
|
||||
const colors = colorSchemes.primary
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.dashboardModelStats.length) return
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
const { colorSchemes } = useChartConfig()
|
||||
const colors = colorSchemes.primary
|
||||
|
||||
chart = new Chart(chartCanvas.value, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sortedStats.value.map(stat => stat.model),
|
||||
datasets: [{
|
||||
data: sortedStats.value.map(stat => stat.requests),
|
||||
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const stat = sortedStats.value[context.dataIndex]
|
||||
const percentage = ((stat.requests / dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) * 100).toFixed(1)
|
||||
return [
|
||||
`${stat.model}: ${percentage}%`,
|
||||
`请求: ${formatNumber(stat.requests)}`,
|
||||
`Tokens: ${formatNumber(stat.totalTokens)}`
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePeriodChange = async () => {
|
||||
await dashboardStore.loadModelStats(modelPeriod.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.dashboardModelStats, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
167
web/admin-spa/src/components/dashboard/UsageTrend.vue
Normal file
167
web/admin-spa/src/components/dashboard/UsageTrend.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<i class="fas fa-chart-area mr-2 text-blue-500"></i>
|
||||
使用趋势
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||
<el-radio-button label="day">按天</el-radio-button>
|
||||
<el-radio-button label="hour">按小时</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select v-model="trendPeriod" size="small" style="width: 120px" @change="handlePeriodChange">
|
||||
<el-option :label="`最近${period.days}天`" :value="period.days" v-for="period in periodOptions" :key="period.days" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
|
||||
const trendPeriod = ref(7)
|
||||
const granularity = ref('day')
|
||||
|
||||
const periodOptions = [
|
||||
{ days: 1, label: '24小时' },
|
||||
{ days: 7, label: '7天' },
|
||||
{ days: 30, label: '30天' }
|
||||
]
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
const { getGradient } = useChartConfig()
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
const labels = dashboardStore.trendData.map(item => {
|
||||
if (granularity.value === 'hour') {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:00`
|
||||
}
|
||||
return item.date
|
||||
})
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map(item => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
yAxisID: 'y',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map(item => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求次数'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token使用量'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePeriodChange = async () => {
|
||||
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
const handleGranularityChange = async () => {
|
||||
// 根据粒度调整时间范围
|
||||
if (granularity.value === 'hour' && trendPeriod.value > 7) {
|
||||
trendPeriod.value = 1
|
||||
}
|
||||
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.trendData, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
412
web/admin-spa/src/components/layout/AppHeader.vue
Normal file
412
web/admin-spa/src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<!-- 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
subtitle="管理后台"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
title-class="text-white"
|
||||
/>
|
||||
<!-- 版本信息 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
||||
<!-- 更新提示 -->
|
||||
<a
|
||||
v-if="versionInfo.hasUpdate"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<i class="fas fa-arrow-up text-[10px]"></i>
|
||||
<span>新版本</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 用户菜单 -->
|
||||
<div class="relative user-menu-container">
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
class="btn btn-primary px-4 py-3 flex items-center gap-2 relative"
|
||||
>
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<span>{{ currentUser.username || 'Admin' }}</span>
|
||||
<i class="fas fa-chevron-down text-xs transition-transform duration-200" :class="{ 'rotate-180': userMenuOpen }"></i>
|
||||
</button>
|
||||
|
||||
<!-- 悬浮菜单 -->
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
|
||||
style="z-index: 999999;"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 版本信息 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">当前版本</span>
|
||||
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
|
||||
</div>
|
||||
<div v-if="versionInfo.hasUpdate" class="mt-2">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-green-600 font-medium">
|
||||
<i class="fas fa-arrow-up mr-1"></i>有新版本
|
||||
</span>
|
||||
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
|
||||
</div>
|
||||
<a
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-external-link-alt mr-1"></i>查看更新
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="versionInfo.checkingUpdate" class="mt-2 text-center text-xs text-gray-500">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>检查更新中...
|
||||
</div>
|
||||
<div v-else class="mt-2 text-center">
|
||||
<!-- 已是最新版提醒 -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="versionInfo.noUpdateMessage" key="message" class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block">
|
||||
<p class="text-xs text-green-700 font-medium">
|
||||
<i class="fas fa-check-circle mr-1"></i>当前已是最新版本
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
key="button"
|
||||
@click="checkForUpdates()"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>检查更新
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openChangePasswordModal"
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i class="fas fa-key text-blue-500"></i>
|
||||
<span>修改账户信息</span>
|
||||
</button>
|
||||
|
||||
<hr class="my-2 border-gray-200">
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-red-500"></i>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改账户信息模态框 -->
|
||||
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-key text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="closeChangePasswordModal"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="changePassword" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
|
||||
<input
|
||||
:value="currentUser.username || 'Admin'"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">当前用户名,输入新用户名以修改</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新用户名</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入新用户名(留空保持不变)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">留空表示不修改用户名</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入当前密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入新密码"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">密码长度至少8位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">确认新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请再次输入新密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changePasswordLoading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="changePasswordLoading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => authStore.user || { username: 'Admin' })
|
||||
|
||||
// OEM设置
|
||||
const oemSettings = computed(() => authStore.oemSettings || {})
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
|
||||
// 版本信息
|
||||
const versionInfo = ref({
|
||||
current: '...',
|
||||
latest: '',
|
||||
hasUpdate: false,
|
||||
checkingUpdate: false,
|
||||
lastChecked: null,
|
||||
releaseInfo: null,
|
||||
noUpdateMessage: false
|
||||
})
|
||||
|
||||
// 用户菜单状态
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
// 修改密码模态框
|
||||
const showChangePasswordModal = ref(false)
|
||||
const changePasswordLoading = ref(false)
|
||||
const changePasswordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
newUsername: ''
|
||||
})
|
||||
|
||||
// 检查更新(同时获取版本信息)
|
||||
const checkForUpdates = async () => {
|
||||
if (versionInfo.value.checkingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
versionInfo.value.checkingUpdate = true
|
||||
|
||||
try {
|
||||
const result = await apiClient.get('/admin/check-updates')
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data
|
||||
|
||||
versionInfo.value.current = data.current
|
||||
versionInfo.value.latest = data.latest
|
||||
versionInfo.value.hasUpdate = data.hasUpdate
|
||||
versionInfo.value.releaseInfo = data.releaseInfo
|
||||
versionInfo.value.lastChecked = new Date()
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('versionInfo', JSON.stringify({
|
||||
current: data.current,
|
||||
latest: data.latest,
|
||||
lastChecked: versionInfo.value.lastChecked,
|
||||
hasUpdate: data.hasUpdate,
|
||||
releaseInfo: data.releaseInfo
|
||||
}))
|
||||
|
||||
// 如果没有更新,显示提醒
|
||||
if (!data.hasUpdate) {
|
||||
versionInfo.value.noUpdateMessage = true
|
||||
// 3秒后自动隐藏提醒
|
||||
setTimeout(() => {
|
||||
versionInfo.value.noUpdateMessage = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
|
||||
// 尝试从localStorage读取缓存的版本信息
|
||||
const cached = localStorage.getItem('versionInfo')
|
||||
if (cached) {
|
||||
const cachedInfo = JSON.parse(cached)
|
||||
versionInfo.value.current = cachedInfo.current || versionInfo.value.current
|
||||
versionInfo.value.latest = cachedInfo.latest
|
||||
versionInfo.value.hasUpdate = cachedInfo.hasUpdate
|
||||
versionInfo.value.releaseInfo = cachedInfo.releaseInfo
|
||||
versionInfo.value.lastChecked = new Date(cachedInfo.lastChecked)
|
||||
}
|
||||
} finally {
|
||||
versionInfo.value.checkingUpdate = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开修改密码弹窗
|
||||
const openChangePasswordModal = () => {
|
||||
changePasswordForm.currentPassword = ''
|
||||
changePasswordForm.newPassword = ''
|
||||
changePasswordForm.confirmPassword = ''
|
||||
changePasswordForm.newUsername = ''
|
||||
showChangePasswordModal.value = true
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// 关闭修改密码弹窗
|
||||
const closeChangePasswordModal = () => {
|
||||
showChangePasswordModal.value = false
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
|
||||
showToast('两次输入的密码不一致', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (changePasswordForm.newPassword.length < 8) {
|
||||
showToast('新密码长度至少8位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
changePasswordLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await apiClient.post('/admin/change-password', {
|
||||
currentPassword: changePasswordForm.currentPassword,
|
||||
newPassword: changePasswordForm.newPassword,
|
||||
newUsername: changePasswordForm.newUsername || undefined
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
const message = changePasswordForm.newUsername ? '账户信息修改成功,请重新登录' : '密码修改成功,请重新登录'
|
||||
showToast(message, 'success')
|
||||
closeChangePasswordModal()
|
||||
|
||||
// 延迟后退出登录
|
||||
setTimeout(() => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} else {
|
||||
showToast(data.message || '修改失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('修改密码失败', 'error')
|
||||
} finally {
|
||||
changePasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
showToast('已安全退出', 'success')
|
||||
}
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (event) => {
|
||||
const userMenuContainer = event.target.closest('.user-menu-container')
|
||||
if (!userMenuContainer && userMenuOpen.value) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
|
||||
// 设置自动检查更新(每小时检查一次)
|
||||
setInterval(() => {
|
||||
checkForUpdates()
|
||||
}, 3600000) // 1小时
|
||||
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 用户菜单样式优化 */
|
||||
.user-menu-dropdown {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* fade过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
71
web/admin-spa/src/components/layout/MainLayout.vue
Normal file
71
web/admin-spa/src/components/layout/MainLayout.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1; min-height: calc(100vh - 240px);">
|
||||
<!-- 标签栏 -->
|
||||
<TabBar :active-tab="activeTab" @tab-change="handleTabChange" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="tab-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<keep-alive :include="['DashboardView', 'ApiKeysView']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 根据路由设置当前激活的标签
|
||||
const activeTab = ref('dashboard')
|
||||
|
||||
const tabRouteMap = {
|
||||
dashboard: '/dashboard',
|
||||
apiKeys: '/api-keys',
|
||||
accounts: '/accounts',
|
||||
tutorial: '/tutorial',
|
||||
settings: '/settings'
|
||||
}
|
||||
|
||||
// 监听路由变化,更新激活的标签
|
||||
watch(() => route.path, (newPath) => {
|
||||
const tabKey = Object.keys(tabRouteMap).find(
|
||||
key => tabRouteMap[key] === newPath
|
||||
)
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabChange = (tabKey) => {
|
||||
activeTab.value = tabKey
|
||||
router.push(tabRouteMap[tabKey])
|
||||
}
|
||||
|
||||
// 加载OEM设置
|
||||
onMounted(() => {
|
||||
authStore.loadOemSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局定义的过渡样式 */
|
||||
</style>
|
||||
38
web/admin-spa/src/components/layout/TabBar.vue
Normal file
38
web/admin-spa/src/components/layout/TabBar.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 mb-6 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="$emit('tab-change', tab.key)"
|
||||
:class="[
|
||||
'tab-btn flex-1 py-3 px-6 text-sm font-semibold transition-all duration-300',
|
||||
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i :class="tab.icon + ' mr-2'"></i>{{ tab.name }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['tab-change'])
|
||||
|
||||
const tabs = [
|
||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '其他设置', icon: 'fas fa-cogs' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .tab-btn 类 */
|
||||
</style>
|
||||
101
web/admin-spa/src/composables/useChartConfig.js
Normal file
101
web/admin-spa/src/composables/useChartConfig.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Chart } from 'chart.js/auto'
|
||||
|
||||
export function useChartConfig() {
|
||||
// 设置Chart.js默认配置
|
||||
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
||||
Chart.defaults.color = '#6b7280'
|
||||
Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(0, 0, 0, 0.8)'
|
||||
Chart.defaults.plugins.tooltip.padding = 12
|
||||
Chart.defaults.plugins.tooltip.cornerRadius = 8
|
||||
Chart.defaults.plugins.tooltip.titleFont.size = 14
|
||||
Chart.defaults.plugins.tooltip.bodyFont.size = 12
|
||||
|
||||
// 创建渐变色
|
||||
const getGradient = (ctx, color, opacity = 0.2) => {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
gradient.addColorStop(0, `${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`)
|
||||
gradient.addColorStop(1, `${color}00`)
|
||||
return gradient
|
||||
}
|
||||
|
||||
// 通用图表选项
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('zh-CN').format(context.parsed.y)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色方案
|
||||
const colorSchemes = {
|
||||
primary: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'],
|
||||
success: ['#10b981', '#059669', '#34d399', '#6ee7b7', '#a7f3d0'],
|
||||
warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'],
|
||||
danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca']
|
||||
}
|
||||
|
||||
return {
|
||||
getGradient,
|
||||
commonOptions,
|
||||
colorSchemes
|
||||
}
|
||||
}
|
||||
49
web/admin-spa/src/composables/useConfirm.js
Normal file
49
web/admin-spa/src/composables/useConfirm.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmOptions = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: '继续',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
export function useConfirm() {
|
||||
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmOptions.value = {
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText
|
||||
}
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
showConfirmModal.value = false
|
||||
if (confirmResolve.value) {
|
||||
confirmResolve.value(true)
|
||||
confirmResolve.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
showConfirmModal.value = false
|
||||
if (confirmResolve.value) {
|
||||
confirmResolve.value(false)
|
||||
confirmResolve.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showConfirmModal,
|
||||
confirmOptions,
|
||||
showConfirm,
|
||||
handleConfirm,
|
||||
handleCancel
|
||||
}
|
||||
}
|
||||
173
web/admin-spa/src/config/api.js
Normal file
173
web/admin-spa/src/config/api.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// API 配置
|
||||
import { APP_CONFIG, getLoginUrl } from './app'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
// 开发环境使用 /webapi 前缀,生产环境不使用前缀
|
||||
export const API_PREFIX = APP_CONFIG.apiPrefix
|
||||
|
||||
// 创建完整的 API URL
|
||||
export function createApiUrl(path) {
|
||||
// 确保路径以 / 开头
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path
|
||||
}
|
||||
return API_PREFIX + path
|
||||
}
|
||||
|
||||
// API 请求的基础配置
|
||||
export function getRequestConfig(token) {
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// 统一的 API 请求类
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseURL = API_PREFIX
|
||||
}
|
||||
|
||||
// 获取认证 token
|
||||
getAuthToken() {
|
||||
const authToken = localStorage.getItem('authToken')
|
||||
return authToken || null
|
||||
}
|
||||
|
||||
// 构建请求配置
|
||||
buildConfig(options = {}) {
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
}
|
||||
|
||||
// 添加认证 token
|
||||
const token = this.getAuthToken()
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// 处理响应
|
||||
async handleResponse(response) {
|
||||
// 401 未授权,需要重新登录
|
||||
if (response.status === 401) {
|
||||
// 如果当前已经在登录页面,不要再次跳转
|
||||
const currentPath = window.location.pathname + window.location.hash
|
||||
const isLoginPage = currentPath.includes('/login') || currentPath.endsWith('/')
|
||||
|
||||
if (!isLoginPage) {
|
||||
localStorage.removeItem('authToken')
|
||||
// 使用统一的登录URL
|
||||
window.location.href = getLoginUrl()
|
||||
}
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// 尝试解析 JSON
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
|
||||
// 如果响应不成功,抛出错误
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 非 JSON 响应
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
async get(url, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config)
|
||||
return await this.handleResponse(response)
|
||||
} catch (error) {
|
||||
console.error('API GET Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// POST 请求
|
||||
async post(url, data = null, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config)
|
||||
return await this.handleResponse(response)
|
||||
} catch (error) {
|
||||
console.error('API POST Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// PUT 请求
|
||||
async put(url, data = null, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config)
|
||||
return await this.handleResponse(response)
|
||||
} catch (error) {
|
||||
console.error('API PUT Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
async delete(url, options = {}) {
|
||||
const fullUrl = createApiUrl(url)
|
||||
const config = this.buildConfig({
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config)
|
||||
return await this.handleResponse(response)
|
||||
} catch (error) {
|
||||
console.error('API DELETE Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const apiClient = new ApiClient()
|
||||
81
web/admin-spa/src/config/apiStats.js
Normal file
81
web/admin-spa/src/config/apiStats.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// API Stats 专用 API 客户端
|
||||
// 与管理员 API 隔离,不需要认证
|
||||
|
||||
class ApiStatsClient {
|
||||
constructor() {
|
||||
this.baseURL = window.location.origin
|
||||
// 开发环境需要为 admin 路径添加 /webapi 前缀
|
||||
this.isDev = import.meta.env.DEV
|
||||
}
|
||||
|
||||
async request(url, options = {}) {
|
||||
try {
|
||||
// 在开发环境中,为 /admin 路径添加 /webapi 前缀
|
||||
if (this.isDev && url.startsWith('/admin')) {
|
||||
url = '/webapi' + url
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `请求失败: ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('API Stats request error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 API Key ID
|
||||
async getKeyId(apiKey) {
|
||||
return this.request('/apiStats/api/get-key-id', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey })
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户统计数据
|
||||
async getUserStats(apiId) {
|
||||
return this.request('/apiStats/api/user-stats', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiId })
|
||||
})
|
||||
}
|
||||
|
||||
// 获取模型使用统计
|
||||
async getUserModelStats(apiId, period = 'daily') {
|
||||
return this.request('/apiStats/api/user-model-stats', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiId, period })
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 OEM 设置(用于网站名称和图标)
|
||||
async getOemSettings() {
|
||||
try {
|
||||
return await this.request('/admin/oem-settings')
|
||||
} catch (error) {
|
||||
console.error('Failed to load OEM settings:', error)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiStatsClient = new ApiStatsClient()
|
||||
28
web/admin-spa/src/config/app.js
Normal file
28
web/admin-spa/src/config/app.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// 应用配置
|
||||
export const APP_CONFIG = {
|
||||
// 应用基础路径
|
||||
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
||||
|
||||
// 应用标题
|
||||
title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - 管理后台',
|
||||
|
||||
// 是否为开发环境
|
||||
isDev: import.meta.env.DEV,
|
||||
|
||||
// API 前缀
|
||||
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
||||
}
|
||||
|
||||
// 获取完整的应用URL
|
||||
export function getAppUrl(path = '') {
|
||||
// 确保路径以 / 开头
|
||||
if (path && !path.startsWith('/')) {
|
||||
path = '/' + path
|
||||
}
|
||||
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
|
||||
}
|
||||
|
||||
// 获取登录页面URL
|
||||
export function getLoginUrl() {
|
||||
return getAppUrl('/login')
|
||||
}
|
||||
27
web/admin-spa/src/main.js
Normal file
27
web/admin-spa/src/main.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/main.css'
|
||||
import './assets/styles/global.css'
|
||||
|
||||
// 创建Vue应用
|
||||
const app = createApp(App)
|
||||
|
||||
// 使用Pinia状态管理
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// 使用路由
|
||||
app.use(router)
|
||||
|
||||
// 使用Element Plus
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
122
web/admin-spa/src/router/index.js
Normal file
122
web/admin-spa/src/router/index.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { APP_CONFIG } from '@/config/app'
|
||||
|
||||
// 路由懒加载
|
||||
const LoginView = () => import('@/views/LoginView.vue')
|
||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||
const DashboardView = () => import('@/views/DashboardView.vue')
|
||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/api-stats'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/api-stats',
|
||||
name: 'ApiStats',
|
||||
component: ApiStatsView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ApiKeys',
|
||||
component: ApiKeysView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Accounts',
|
||||
component: AccountsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tutorial',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Tutorial',
|
||||
component: TutorialView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Settings',
|
||||
component: SettingsView
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(APP_CONFIG.basePath),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: authStore.isAuthenticated
|
||||
})
|
||||
|
||||
// API Stats 页面不需要认证,直接放行
|
||||
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
|
||||
next()
|
||||
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
334
web/admin-spa/src/stores/accounts.js
Normal file
334
web/admin-spa/src/stores/accounts.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
// 状态
|
||||
const claudeAccounts = ref([])
|
||||
const geminiAccounts = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const sortBy = ref('')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
// Actions
|
||||
|
||||
// 获取Claude账户列表
|
||||
const fetchClaudeAccounts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.get('/admin/claude-accounts')
|
||||
if (response.success) {
|
||||
claudeAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Gemini账户列表
|
||||
const fetchGeminiAccounts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.get('/admin/gemini-accounts')
|
||||
if (response.success) {
|
||||
geminiAccounts.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
const fetchAllAccounts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchClaudeAccounts(),
|
||||
fetchGeminiAccounts()
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建Claude账户
|
||||
const createClaudeAccount = async (data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-accounts', data)
|
||||
if (response.success) {
|
||||
await fetchClaudeAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '创建Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建Gemini账户
|
||||
const createGeminiAccount = async (data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post('/admin/gemini-accounts', data)
|
||||
if (response.success) {
|
||||
await fetchGeminiAccounts()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '创建Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Claude账户
|
||||
const updateClaudeAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/claude-accounts/${id}`, data)
|
||||
if (response.success) {
|
||||
await fetchClaudeAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '更新Claude账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Gemini账户
|
||||
const updateGeminiAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/gemini-accounts/${id}`, data)
|
||||
if (response.success) {
|
||||
await fetchGeminiAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '更新Gemini账户失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换账户状态
|
||||
const toggleAccount = async (platform, id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const endpoint = platform === 'claude'
|
||||
? `/admin/claude-accounts/${id}/toggle`
|
||||
: `/admin/gemini-accounts/${id}/toggle`
|
||||
|
||||
const response = await apiClient.put(endpoint)
|
||||
if (response.success) {
|
||||
if (platform === 'claude') {
|
||||
await fetchClaudeAccounts()
|
||||
} else {
|
||||
await fetchGeminiAccounts()
|
||||
}
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '切换状态失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账户
|
||||
const deleteAccount = async (platform, id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const endpoint = platform === 'claude'
|
||||
? `/admin/claude-accounts/${id}`
|
||||
: `/admin/gemini-accounts/${id}`
|
||||
|
||||
const response = await apiClient.delete(endpoint)
|
||||
if (response.success) {
|
||||
if (platform === 'claude') {
|
||||
await fetchClaudeAccounts()
|
||||
} else {
|
||||
await fetchGeminiAccounts()
|
||||
}
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新Claude Token
|
||||
const refreshClaudeToken = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/claude-accounts/${id}/refresh`)
|
||||
if (response.success) {
|
||||
await fetchClaudeAccounts()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || 'Token刷新失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Claude OAuth URL
|
||||
const generateClaudeAuthUrl = async (proxyConfig) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-accounts/generate-auth-url', proxyConfig)
|
||||
if (response.success) {
|
||||
return response.data.authUrl // 返回authUrl字符串而不是整个对象
|
||||
} else {
|
||||
throw new Error(response.message || '生成授权URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 交换Claude OAuth Code
|
||||
const exchangeClaudeCode = async (data) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-accounts/exchange-code', data)
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '交换授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Gemini OAuth URL
|
||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/gemini-accounts/generate-auth-url', proxyConfig)
|
||||
if (response.success) {
|
||||
return response.data.authUrl // 返回authUrl字符串而不是整个对象
|
||||
} else {
|
||||
throw new Error(response.message || '生成授权URL失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 交换Gemini OAuth Code
|
||||
const exchangeGeminiCode = async (data) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/gemini-accounts/exchange-code', data)
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '交换授权码失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 排序账户
|
||||
const sortAccounts = (field) => {
|
||||
if (sortBy.value === field) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortBy.value = field
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
// 重置store
|
||||
const reset = () => {
|
||||
claudeAccounts.value = []
|
||||
geminiAccounts.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
sortBy.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
claudeAccounts,
|
||||
geminiAccounts,
|
||||
loading,
|
||||
error,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
// Actions
|
||||
fetchClaudeAccounts,
|
||||
fetchGeminiAccounts,
|
||||
fetchAllAccounts,
|
||||
createClaudeAccount,
|
||||
createGeminiAccount,
|
||||
updateClaudeAccount,
|
||||
updateGeminiAccount,
|
||||
toggleAccount,
|
||||
deleteAccount,
|
||||
refreshClaudeToken,
|
||||
generateClaudeAuthUrl,
|
||||
exchangeClaudeCode,
|
||||
generateGeminiAuthUrl,
|
||||
exchangeGeminiCode,
|
||||
sortAccounts,
|
||||
reset
|
||||
}
|
||||
})
|
||||
192
web/admin-spa/src/stores/apiKeys.js
Normal file
192
web/admin-spa/src/stores/apiKeys.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
// 状态
|
||||
const apiKeys = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const statsTimeRange = ref('all')
|
||||
const sortBy = ref('')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
// Actions
|
||||
|
||||
// 获取API Keys列表
|
||||
const fetchApiKeys = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.get('/admin/api-keys')
|
||||
if (response.success) {
|
||||
apiKeys.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取API Keys失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建API Key
|
||||
const createApiKey = async (data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post('/admin/api-keys', data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '创建API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新API Key
|
||||
const updateApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/api-keys/${id}`, data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '更新API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换API Key状态
|
||||
const toggleApiKey = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/api-keys/${id}/toggle`)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '切换状态失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 续期API Key
|
||||
const renewApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/api-keys/${id}/renew`, data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '续期失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
const deleteApiKey = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/api-keys/${id}`)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API Key统计
|
||||
const fetchApiKeyStats = async (id, timeRange = 'all') => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/api-keys/${id}/stats`, {
|
||||
params: { timeRange }
|
||||
})
|
||||
if (response.success) {
|
||||
return response.stats
|
||||
} else {
|
||||
throw new Error(response.message || '获取统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取API Key统计失败:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
if (sortBy.value === field) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortBy.value = field
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
// 重置store
|
||||
const reset = () => {
|
||||
apiKeys.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
statsTimeRange.value = 'all'
|
||||
sortBy.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
apiKeys,
|
||||
loading,
|
||||
error,
|
||||
statsTimeRange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
// Actions
|
||||
fetchApiKeys,
|
||||
createApiKey,
|
||||
updateApiKey,
|
||||
toggleApiKey,
|
||||
renewApiKey,
|
||||
deleteApiKey,
|
||||
fetchApiKeyStats,
|
||||
sortApiKeys,
|
||||
reset
|
||||
}
|
||||
})
|
||||
343
web/admin-spa/src/stores/apistats.js
Normal file
343
web/admin-spa/src/stores/apistats.js
Normal file
@@ -0,0 +1,343 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiStatsClient } from '@/config/apiStats'
|
||||
|
||||
export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 状态
|
||||
const apiKey = ref('')
|
||||
const apiId = ref(null)
|
||||
const loading = ref(false)
|
||||
const modelStatsLoading = ref(false)
|
||||
const oemLoading = ref(true)
|
||||
const error = ref('')
|
||||
const statsPeriod = ref('daily')
|
||||
const statsData = ref(null)
|
||||
const modelStats = ref([])
|
||||
const dailyStats = ref(null)
|
||||
const monthlyStats = ref(null)
|
||||
const oemSettings = ref({
|
||||
siteName: '',
|
||||
siteIcon: '',
|
||||
siteIconData: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const currentPeriodData = computed(() => {
|
||||
const defaultData = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
|
||||
if (statsPeriod.value === 'daily') {
|
||||
return dailyStats.value || defaultData
|
||||
} else {
|
||||
return monthlyStats.value || defaultData
|
||||
}
|
||||
})
|
||||
|
||||
const usagePercentages = computed(() => {
|
||||
if (!statsData.value || !currentPeriodData.value) {
|
||||
return {
|
||||
tokenUsage: 0,
|
||||
costUsage: 0,
|
||||
requestUsage: 0
|
||||
}
|
||||
}
|
||||
|
||||
const current = currentPeriodData.value
|
||||
const limits = statsData.value.limits
|
||||
|
||||
return {
|
||||
tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
|
||||
costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
|
||||
requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0
|
||||
}
|
||||
})
|
||||
|
||||
// Actions
|
||||
|
||||
// 查询统计数据
|
||||
async function queryStats() {
|
||||
if (!apiKey.value.trim()) {
|
||||
error.value = '请输入 API Key'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
statsData.value = null
|
||||
modelStats.value = []
|
||||
apiId.value = null
|
||||
|
||||
try {
|
||||
// 获取 API Key ID
|
||||
const idResult = await apiStatsClient.getKeyId(apiKey.value)
|
||||
|
||||
if (idResult.success) {
|
||||
apiId.value = idResult.data.id
|
||||
|
||||
// 使用 apiId 查询统计数据
|
||||
const statsResult = await apiStatsClient.getUserStats(apiId.value)
|
||||
|
||||
if (statsResult.success) {
|
||||
statsData.value = statsResult.data
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await loadAllPeriodStats()
|
||||
|
||||
// 清除错误信息
|
||||
error.value = ''
|
||||
|
||||
// 更新 URL
|
||||
updateURL()
|
||||
} else {
|
||||
throw new Error(statsResult.message || '查询失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error(idResult.message || '获取 API Key ID 失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Query stats error:', err)
|
||||
error.value = err.message || '查询统计数据失败,请检查您的 API Key 是否正确'
|
||||
statsData.value = null
|
||||
modelStats.value = []
|
||||
apiId.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有时间段的统计数据
|
||||
async function loadAllPeriodStats() {
|
||||
if (!apiId.value) return
|
||||
|
||||
// 并行加载今日和本月的数据
|
||||
await Promise.all([
|
||||
loadPeriodStats('daily'),
|
||||
loadPeriodStats('monthly')
|
||||
])
|
||||
|
||||
// 加载当前选择时间段的模型统计
|
||||
await loadModelStats(statsPeriod.value)
|
||||
}
|
||||
|
||||
// 加载指定时间段的统计数据
|
||||
async function loadPeriodStats(period) {
|
||||
try {
|
||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
||||
|
||||
if (result.success) {
|
||||
// 计算汇总数据
|
||||
const modelData = result.data || []
|
||||
const summary = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
|
||||
modelData.forEach(model => {
|
||||
summary.requests += model.requests || 0
|
||||
summary.inputTokens += model.inputTokens || 0
|
||||
summary.outputTokens += model.outputTokens || 0
|
||||
summary.cacheCreateTokens += model.cacheCreateTokens || 0
|
||||
summary.cacheReadTokens += model.cacheReadTokens || 0
|
||||
summary.allTokens += model.allTokens || 0
|
||||
summary.cost += model.costs?.total || 0
|
||||
})
|
||||
|
||||
summary.formattedCost = formatCost(summary.cost)
|
||||
|
||||
// 存储到对应的时间段数据
|
||||
if (period === 'daily') {
|
||||
dailyStats.value = summary
|
||||
} else {
|
||||
monthlyStats.value = summary
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load ${period} stats:`, result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Load ${period} stats error:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型统计数据
|
||||
async function loadModelStats(period = 'daily') {
|
||||
if (!apiId.value) return
|
||||
|
||||
modelStatsLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
||||
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
} else {
|
||||
throw new Error(result.message || '加载模型统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load model stats error:', err)
|
||||
modelStats.value = []
|
||||
} finally {
|
||||
modelStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换时间范围
|
||||
async function switchPeriod(period) {
|
||||
if (statsPeriod.value === period || modelStatsLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
statsPeriod.value = period
|
||||
|
||||
// 如果对应时间段的数据还没有加载,则加载它
|
||||
if ((period === 'daily' && !dailyStats.value) ||
|
||||
(period === 'monthly' && !monthlyStats.value)) {
|
||||
await loadPeriodStats(period)
|
||||
}
|
||||
|
||||
// 加载对应的模型统计
|
||||
await loadModelStats(period)
|
||||
}
|
||||
|
||||
// 使用 apiId 直接加载数据
|
||||
async function loadStatsWithApiId() {
|
||||
if (!apiId.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
statsData.value = null
|
||||
modelStats.value = []
|
||||
|
||||
try {
|
||||
const result = await apiStatsClient.getUserStats(apiId.value)
|
||||
|
||||
if (result.success) {
|
||||
statsData.value = result.data
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await loadAllPeriodStats()
|
||||
|
||||
// 清除错误信息
|
||||
error.value = ''
|
||||
} else {
|
||||
throw new Error(result.message || '查询失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load stats with apiId error:', err)
|
||||
error.value = err.message || '查询统计数据失败'
|
||||
statsData.value = null
|
||||
modelStats.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 OEM 设置
|
||||
async function loadOemSettings() {
|
||||
oemLoading.value = true
|
||||
try {
|
||||
const result = await apiStatsClient.getOemSettings()
|
||||
if (result && result.success && result.data) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading OEM settings:', err)
|
||||
// 失败时使用默认值
|
||||
oemSettings.value = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: ''
|
||||
}
|
||||
} finally {
|
||||
oemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
|
||||
// 格式化费用
|
||||
function formatCost(cost) {
|
||||
if (typeof cost !== 'number' || cost === 0) {
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
// 根据数值大小选择精度
|
||||
if (cost >= 1) {
|
||||
return '$' + cost.toFixed(2)
|
||||
} else if (cost >= 0.01) {
|
||||
return '$' + cost.toFixed(4)
|
||||
} else {
|
||||
return '$' + cost.toFixed(6)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 URL
|
||||
function updateURL() {
|
||||
if (apiId.value) {
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('apiId', apiId.value)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除数据
|
||||
function clearData() {
|
||||
statsData.value = null
|
||||
modelStats.value = []
|
||||
dailyStats.value = null
|
||||
monthlyStats.value = null
|
||||
error.value = ''
|
||||
statsPeriod.value = 'daily'
|
||||
apiId.value = null
|
||||
}
|
||||
|
||||
// 重置
|
||||
function reset() {
|
||||
apiKey.value = ''
|
||||
clearData()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
apiKey,
|
||||
apiId,
|
||||
loading,
|
||||
modelStatsLoading,
|
||||
oemLoading,
|
||||
error,
|
||||
statsPeriod,
|
||||
statsData,
|
||||
modelStats,
|
||||
dailyStats,
|
||||
monthlyStats,
|
||||
oemSettings,
|
||||
|
||||
// Computed
|
||||
currentPeriodData,
|
||||
usagePercentages,
|
||||
|
||||
// Actions
|
||||
queryStats,
|
||||
loadAllPeriodStats,
|
||||
loadPeriodStats,
|
||||
loadModelStats,
|
||||
switchPeriod,
|
||||
loadStatsWithApiId,
|
||||
loadOemSettings,
|
||||
clearData,
|
||||
reset
|
||||
}
|
||||
})
|
||||
130
web/admin-spa/src/stores/auth.js
Normal file
130
web/admin-spa/src/stores/auth.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
const isLoggedIn = ref(false)
|
||||
const authToken = ref(localStorage.getItem('authToken') || '')
|
||||
const username = ref('')
|
||||
const loginError = ref('')
|
||||
const loginLoading = ref(false)
|
||||
const oemSettings = ref({
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
faviconData: ''
|
||||
})
|
||||
const oemLoading = ref(true)
|
||||
|
||||
// 计算属性
|
||||
const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value)
|
||||
const token = computed(() => authToken.value)
|
||||
const user = computed(() => ({ username: username.value }))
|
||||
|
||||
// 方法
|
||||
async function login(credentials) {
|
||||
loginLoading.value = true
|
||||
loginError.value = ''
|
||||
|
||||
try {
|
||||
const result = await apiClient.post('/web/auth/login', credentials)
|
||||
|
||||
if (result.success) {
|
||||
authToken.value = result.token
|
||||
username.value = credentials.username
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('authToken', result.token)
|
||||
|
||||
await router.push('/dashboard')
|
||||
} else {
|
||||
loginError.value = result.message || '登录失败'
|
||||
}
|
||||
} catch (error) {
|
||||
loginError.value = error.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
isLoggedIn.value = false
|
||||
authToken.value = ''
|
||||
username.value = ''
|
||||
localStorage.removeItem('authToken')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
if (authToken.value) {
|
||||
isLoggedIn.value = true
|
||||
// 验证token有效性
|
||||
verifyToken()
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyToken() {
|
||||
try {
|
||||
// 使用 dashboard 端点来验证 token
|
||||
// 如果 token 无效,会抛出错误
|
||||
const result = await apiClient.get('/admin/dashboard')
|
||||
if (!result.success) {
|
||||
logout()
|
||||
}
|
||||
} catch (error) {
|
||||
// token 无效,需要重新登录
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOemSettings() {
|
||||
oemLoading.value = true
|
||||
try {
|
||||
const result = await apiClient.get('/admin/oem-settings')
|
||||
if (result.success && result.data) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
// 设置favicon
|
||||
if (result.data.faviconData) {
|
||||
const link = document.querySelector("link[rel*='icon']") || document.createElement('link')
|
||||
link.type = 'image/x-icon'
|
||||
link.rel = 'shortcut icon'
|
||||
link.href = result.data.faviconData
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
|
||||
// 设置页面标题
|
||||
if (result.data.siteName) {
|
||||
document.title = `${result.data.siteName} - 管理后台`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载OEM设置失败:', error)
|
||||
} finally {
|
||||
oemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isLoggedIn,
|
||||
authToken,
|
||||
username,
|
||||
loginError,
|
||||
loginLoading,
|
||||
oemSettings,
|
||||
oemLoading,
|
||||
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
token,
|
||||
user,
|
||||
|
||||
// 方法
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
loadOemSettings
|
||||
}
|
||||
})
|
||||
41
web/admin-spa/src/stores/clients.js
Normal file
41
web/admin-spa/src/stores/clients.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useClientsStore = defineStore('clients', {
|
||||
state: () => ({
|
||||
supportedClients: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadSupportedClients() {
|
||||
if (this.supportedClients.length > 0) {
|
||||
// 如果已经加载过,不重复加载
|
||||
return this.supportedClients
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/admin/supported-clients')
|
||||
|
||||
if (response.success) {
|
||||
this.supportedClients = response.data || []
|
||||
} else {
|
||||
this.error = response.message || '加载支持的客户端失败'
|
||||
console.error('Failed to load supported clients:', this.error)
|
||||
}
|
||||
|
||||
return this.supportedClients
|
||||
} catch (error) {
|
||||
this.error = error.message || '加载支持的客户端失败'
|
||||
console.error('Error loading supported clients:', error)
|
||||
return []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
450
web/admin-spa/src/stores/dashboard.js
Normal file
450
web/admin-spa/src/stores/dashboard.js
Normal file
@@ -0,0 +1,450 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const dashboardData = ref({
|
||||
totalApiKeys: 0,
|
||||
activeApiKeys: 0,
|
||||
totalAccounts: 0,
|
||||
activeAccounts: 0,
|
||||
rateLimitedAccounts: 0,
|
||||
todayRequests: 0,
|
||||
totalRequests: 0,
|
||||
todayTokens: 0,
|
||||
todayInputTokens: 0,
|
||||
todayOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheCreateTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
todayCacheCreateTokens: 0,
|
||||
todayCacheReadTokens: 0,
|
||||
systemRPM: 0,
|
||||
systemTPM: 0,
|
||||
systemStatus: '正常',
|
||||
uptime: 0
|
||||
})
|
||||
|
||||
const costsData = ref({
|
||||
todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } },
|
||||
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
|
||||
})
|
||||
|
||||
const modelStats = ref([])
|
||||
const trendData = ref([])
|
||||
const dashboardModelStats = ref([])
|
||||
const apiKeysTrendData = ref({
|
||||
data: [],
|
||||
topApiKeys: [],
|
||||
totalApiKeys: 0
|
||||
})
|
||||
|
||||
// 日期筛选
|
||||
const dateFilter = ref({
|
||||
type: 'preset', // preset 或 custom
|
||||
preset: '7days', // today, 7days, 30days
|
||||
customStart: '',
|
||||
customEnd: '',
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
})
|
||||
|
||||
// 趋势图粒度
|
||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||
|
||||
// 默认时间
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
|
||||
// 计算属性
|
||||
const formattedUptime = computed(() => {
|
||||
const seconds = dashboardData.value.uptime
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`
|
||||
} else {
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
async function loadDashboardData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
|
||||
apiClient.get('/admin/dashboard'),
|
||||
apiClient.get('/admin/usage-costs?period=today'),
|
||||
apiClient.get('/admin/usage-costs?period=all')
|
||||
])
|
||||
|
||||
if (dashboardResponse.success) {
|
||||
const overview = dashboardResponse.data.overview || {}
|
||||
const recentActivity = dashboardResponse.data.recentActivity || {}
|
||||
const systemAverages = dashboardResponse.data.systemAverages || {}
|
||||
const systemHealth = dashboardResponse.data.systemHealth || {}
|
||||
|
||||
dashboardData.value = {
|
||||
totalApiKeys: overview.totalApiKeys || 0,
|
||||
activeApiKeys: overview.activeApiKeys || 0,
|
||||
totalAccounts: overview.totalClaudeAccounts || 0,
|
||||
activeAccounts: overview.activeClaudeAccounts || 0,
|
||||
rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0,
|
||||
todayRequests: recentActivity.requestsToday || 0,
|
||||
totalRequests: overview.totalRequestsUsed || 0,
|
||||
todayTokens: recentActivity.tokensToday || 0,
|
||||
todayInputTokens: recentActivity.inputTokensToday || 0,
|
||||
todayOutputTokens: recentActivity.outputTokensToday || 0,
|
||||
totalTokens: overview.totalTokensUsed || 0,
|
||||
totalInputTokens: overview.totalInputTokensUsed || 0,
|
||||
totalOutputTokens: overview.totalOutputTokensUsed || 0,
|
||||
totalCacheCreateTokens: overview.totalCacheCreateTokensUsed || 0,
|
||||
totalCacheReadTokens: overview.totalCacheReadTokensUsed || 0,
|
||||
todayCacheCreateTokens: recentActivity.cacheCreateTokensToday || 0,
|
||||
todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0,
|
||||
systemRPM: systemAverages.rpm || 0,
|
||||
systemTPM: systemAverages.tpm || 0,
|
||||
systemStatus: systemHealth.redisConnected ? '正常' : '异常',
|
||||
uptime: systemHealth.uptime || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 更新费用数据
|
||||
if (todayCostsResponse.success && totalCostsResponse.success) {
|
||||
costsData.value = {
|
||||
todayCosts: todayCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } },
|
||||
totalCosts: totalCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } }
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载仪表板数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageTrend(days = 7, granularity = 'day') {
|
||||
try {
|
||||
let url = '/admin/usage-trend?'
|
||||
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度,传递开始和结束时间
|
||||
url += `granularity=hour`
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
url += `granularity=day&days=${days}`
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url)
|
||||
if (response.success) {
|
||||
trendData.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载使用趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelStats(period = 'daily') {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/model-stats?period=${period}`)
|
||||
if (response.success) {
|
||||
dashboardModelStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeysTrend(metric = 'requests') {
|
||||
try {
|
||||
let url = '/admin/api-keys-usage-trend?'
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度,传递开始和结束时间
|
||||
url += `granularity=hour`
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
const days = dateFilter.value.type === 'preset'
|
||||
? (dateFilter.value.preset === 'today' ? 1 : dateFilter.value.preset === '7days' ? 7 : 30)
|
||||
: calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
|
||||
url += `granularity=day&days=${days}`
|
||||
}
|
||||
|
||||
url += `&metric=${metric}`
|
||||
|
||||
const response = await apiClient.get(url)
|
||||
if (response.success) {
|
||||
apiKeysTrendData.value = {
|
||||
data: response.data || [],
|
||||
topApiKeys: response.topApiKeys || [],
|
||||
totalApiKeys: response.totalApiKeys || 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载API Keys趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 日期筛选相关方法
|
||||
function setDateFilterPreset(preset) {
|
||||
dateFilter.value.type = 'preset'
|
||||
dateFilter.value.preset = preset
|
||||
|
||||
// 根据预设计算并设置具体的日期范围
|
||||
const option = dateFilter.value.presetOptions.find(opt => opt.value === preset)
|
||||
if (option) {
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度的预设
|
||||
switch (preset) {
|
||||
case 'last24h':
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endDate = now
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(now)
|
||||
startDate.setDate(now.getDate() - 1)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
break
|
||||
case 'dayBefore':
|
||||
startDate = new Date(now)
|
||||
startDate.setDate(now.getDate() - 2)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 天粒度的预设
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
if (preset === 'today') {
|
||||
// 今日:从凌晨开始
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else {
|
||||
// 其他预设:按天数计算
|
||||
startDate.setDate(now.getDate() - (option.days - 1))
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
}
|
||||
|
||||
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
|
||||
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
|
||||
|
||||
// 设置 customRange 为 Element Plus 需要的格式
|
||||
const formatDate = (date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
formatDate(startDate),
|
||||
formatDate(endDate)
|
||||
]
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
}
|
||||
|
||||
function onCustomDateRangeChange(value) {
|
||||
if (value && value.length === 2) {
|
||||
dateFilter.value.type = 'custom'
|
||||
dateFilter.value.preset = '' // 清除预设选择
|
||||
dateFilter.value.customRange = value
|
||||
dateFilter.value.customStart = value[0].split(' ')[0]
|
||||
dateFilter.value.customEnd = value[1].split(' ')[0]
|
||||
|
||||
// 检查日期范围限制
|
||||
const start = new Date(value[0])
|
||||
const end = new Date(value[1])
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度:限制 24 小时
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast('小时粒度下日期范围不能超过24小时', 'warning')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 天粒度:限制 31 天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 31) {
|
||||
showToast('日期范围不能超过 31 天', 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? '7days' : '7days')
|
||||
}
|
||||
}
|
||||
|
||||
function setTrendGranularity(granularity) {
|
||||
trendGranularity.value = granularity
|
||||
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
if (dateFilter.value.type === 'custom' && dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
const start = new Date(dateFilter.value.customRange[0])
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
||||
setDateFilterPreset('last24h')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
|
||||
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('last24h')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 天粒度
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('7days')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
}
|
||||
|
||||
async function refreshChartsData() {
|
||||
// 根据当前筛选条件刷新数据
|
||||
let days
|
||||
let modelPeriod = 'monthly'
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度
|
||||
days = 1 // 小时粒度默认查看1天的数据
|
||||
modelPeriod = 'daily' // 小时粒度使用日统计
|
||||
} else {
|
||||
// 天粒度
|
||||
days = option ? option.days : 7
|
||||
// 设置模型统计期间
|
||||
if (dateFilter.value.preset === 'today') {
|
||||
modelPeriod = 'daily'
|
||||
} else {
|
||||
modelPeriod = 'monthly'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自定义日期范围
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度下的自定义范围,计算小时数
|
||||
const start = new Date(dateFilter.value.customRange[0])
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
const hoursDiff = Math.ceil((end - start) / (1000 * 60 * 60))
|
||||
days = Math.ceil(hoursDiff / 24) || 1
|
||||
} else {
|
||||
days = calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
|
||||
}
|
||||
modelPeriod = 'daily' // 自定义范围使用日统计
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadUsageTrend(days, trendGranularity.value),
|
||||
loadModelStats(modelPeriod),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value)
|
||||
])
|
||||
}
|
||||
|
||||
function calculateDaysBetween(start, end) {
|
||||
if (!start || !end) return 7
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const diffTime = Math.abs(endDate - startDate)
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays || 7
|
||||
}
|
||||
|
||||
function disabledDate(date) {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
dashboardData,
|
||||
costsData,
|
||||
modelStats,
|
||||
trendData,
|
||||
dashboardModelStats,
|
||||
apiKeysTrendData,
|
||||
dateFilter,
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
defaultTime,
|
||||
|
||||
// 计算属性
|
||||
formattedUptime,
|
||||
|
||||
// 方法
|
||||
loadDashboardData,
|
||||
loadUsageTrend,
|
||||
loadModelStats,
|
||||
loadApiKeysTrend,
|
||||
setDateFilterPreset,
|
||||
onCustomDateRangeChange,
|
||||
setTrendGranularity,
|
||||
refreshChartsData,
|
||||
disabledDate
|
||||
}
|
||||
})
|
||||
151
web/admin-spa/src/stores/settings.js
Normal file
151
web/admin-spa/src/stores/settings.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 状态
|
||||
const oemSettings = ref({
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
updatedAt: null
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 移除自定义API请求方法,使用统一的apiClient
|
||||
|
||||
// Actions
|
||||
const loadOemSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await apiClient.get('/admin/oem-settings')
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
// 应用设置到页面
|
||||
applyOemSettings()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to load OEM settings:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveOemSettings = async (settings) => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await apiClient.put('/admin/oem-settings', settings)
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
// 应用设置到页面
|
||||
applyOemSettings()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to save OEM settings:', error)
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetOemSettings = async () => {
|
||||
const defaultSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
oemSettings.value = { ...defaultSettings }
|
||||
return await saveOemSettings(defaultSettings)
|
||||
}
|
||||
|
||||
// 应用OEM设置到页面
|
||||
const applyOemSettings = () => {
|
||||
// 更新页面标题
|
||||
if (oemSettings.value.siteName) {
|
||||
document.title = `${oemSettings.value.siteName} - 管理后台`
|
||||
}
|
||||
|
||||
// 更新favicon
|
||||
if (oemSettings.value.siteIconData || oemSettings.value.siteIcon) {
|
||||
const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link')
|
||||
favicon.rel = 'icon'
|
||||
favicon.href = oemSettings.value.siteIconData || oemSettings.value.siteIcon
|
||||
if (!document.querySelector('link[rel="icon"]')) {
|
||||
document.head.appendChild(favicon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件上传
|
||||
const validateIconFile = (file) => {
|
||||
const errors = []
|
||||
|
||||
// 检查文件大小 (350KB)
|
||||
if (file.size > 350 * 1024) {
|
||||
errors.push('图标文件大小不能超过 350KB')
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
// 将文件转换为Base64
|
||||
const fileToBase64 = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
oemSettings,
|
||||
loading,
|
||||
saving,
|
||||
|
||||
// Actions
|
||||
loadOemSettings,
|
||||
saveOemSettings,
|
||||
resetOemSettings,
|
||||
applyOemSettings,
|
||||
formatDateTime,
|
||||
validateIconFile,
|
||||
fileToBase64
|
||||
}
|
||||
})
|
||||
74
web/admin-spa/src/utils/format.js
Normal file
74
web/admin-spa/src/utils/format.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// 数字格式化函数
|
||||
export function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0'
|
||||
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
if (absNum >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B'
|
||||
} else if (absNum >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M'
|
||||
} else if (absNum >= 1e3) {
|
||||
return (num / 1e3).toFixed(1) + 'K'
|
||||
}
|
||||
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 日期格式化函数
|
||||
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
|
||||
// 相对时间格式化
|
||||
export function formatRelativeTime(date) {
|
||||
if (!date) return ''
|
||||
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diffMs = now - past
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}天前`
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}小时前`
|
||||
} else if (diffMins > 0) {
|
||||
return `${diffMins}分钟前`
|
||||
} else {
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
// 字节格式化
|
||||
export function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
68
web/admin-spa/src/utils/toast.js
Normal file
68
web/admin-spa/src/utils/toast.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Toast 通知管理
|
||||
let toastContainer = null
|
||||
let toastId = 0
|
||||
|
||||
export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
// 创建容器
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div')
|
||||
toastContainer.id = 'toast-container'
|
||||
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;'
|
||||
document.body.appendChild(toastContainer)
|
||||
}
|
||||
|
||||
// 创建 toast
|
||||
const id = ++toastId
|
||||
const toast = document.createElement('div')
|
||||
toast.className = `toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${type}`
|
||||
toast.style.cssText = `
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
margin-bottom: 16px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
`
|
||||
|
||||
const iconMap = {
|
||||
success: 'fas fa-check-circle',
|
||||
error: 'fas fa-times-circle',
|
||||
warning: 'fas fa-exclamation-triangle',
|
||||
info: 'fas fa-info-circle'
|
||||
}
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="${iconMap[type]} text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
|
||||
<p class="text-sm opacity-90 leading-relaxed">${message}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
toastContainer.appendChild(toast)
|
||||
|
||||
// 触发动画
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)'
|
||||
}, 10)
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)'
|
||||
setTimeout(() => {
|
||||
toast.remove()
|
||||
}, 300)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
@@ -0,0 +1,923 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
|
||||
<p class="text-gray-600">管理和监控您的 API 密钥</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Token统计时间范围选择 -->
|
||||
<select
|
||||
v-model="apiKeyStatsTimeRange"
|
||||
@change="loadApiKeys()"
|
||||
class="form-input px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="today">今日</option>
|
||||
<option value="7days">最近7天</option>
|
||||
<option value="monthly">本月</option>
|
||||
<option value="all">全部时间</option>
|
||||
</select>
|
||||
<button
|
||||
@click.stop="openCreateApiKeyModal"
|
||||
class="btn btn-primary px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i>创建新 Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="apiKeysLoading" class="text-center py-12">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">正在加载 API Keys...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="apiKeys.length === 0" class="text-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-gray-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-gray-500 text-lg">暂无 API Keys</p>
|
||||
<p class="text-gray-400 text-sm mt-2">点击上方按钮创建您的第一个 API Key</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
|
||||
名称
|
||||
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
|
||||
状态
|
||||
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
使用统计
|
||||
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
|
||||
(费用
|
||||
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
|
||||
创建时间
|
||||
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
|
||||
过期时间
|
||||
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200/50">
|
||||
<template v-for="key in sortedApiKeys" :key="key.id">
|
||||
<!-- API Key 主行 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-key text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ key.id }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="key.claudeAccountId">
|
||||
<i class="fas fa-link mr-1"></i>
|
||||
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-share-alt mr-1"></i>
|
||||
使用共享池
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
|
||||
{{ (key.apiKey || '').substring(0, 20) }}...
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||
<div :class="['w-2 h-2 rounded-full mr-2',
|
||||
key.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
{{ key.isActive ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<!-- 请求统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">请求数:</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
|
||||
</div>
|
||||
<!-- Token统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Token:</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- 费用统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">费用:</span>
|
||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
||||
</div>
|
||||
<!-- 每日费用限制 -->
|
||||
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">今日费用:</span>
|
||||
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
|
||||
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 并发限制 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">并发限制:</span>
|
||||
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<!-- 当前并发数 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">当前并发:</span>
|
||||
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
|
||||
{{ key.currentConcurrency || 0 }}
|
||||
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 时间窗口限流 -->
|
||||
<div v-if="key.rateLimitWindow > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">时间窗口:</span>
|
||||
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
|
||||
</div>
|
||||
<!-- 请求次数限制 -->
|
||||
<div v-if="key.rateLimitRequests > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">请求限制:</span>
|
||||
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} 次/窗口</span>
|
||||
</div>
|
||||
<!-- 输入/输出Token -->
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
|
||||
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- 缓存Token细节 -->
|
||||
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
|
||||
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
|
||||
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- RPM/TPM -->
|
||||
<div class="flex justify-between text-xs text-blue-600">
|
||||
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
|
||||
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
|
||||
</div>
|
||||
<!-- 今日统计 -->
|
||||
<div class="pt-1 border-t border-gray-100">
|
||||
<div class="flex justify-between text-xs text-green-600">
|
||||
<span>今日: {{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }}次</span>
|
||||
<span>{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}T</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型分布按钮 -->
|
||||
<div class="pt-2">
|
||||
<button @click="toggleApiKeyModelStats(key.id)" v-if="key && key.id" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']"></i>
|
||||
模型使用分布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div v-if="key.expiresAt">
|
||||
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
已过期
|
||||
</div>
|
||||
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(key.expiresAt) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-600">
|
||||
{{ formatExpireDate(key.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">
|
||||
<i class="fas fa-infinity mr-1"></i>
|
||||
永不过期
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="copyApiStatsLink(key)"
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
|
||||
title="复制统计页面链接"
|
||||
>
|
||||
<i class="fas fa-chart-bar mr-1"></i>统计
|
||||
</button>
|
||||
<button
|
||||
@click="openEditApiKeyModal(key)"
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
|
||||
@click="openRenewApiKeyModal(key)"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>续期
|
||||
</button>
|
||||
<button
|
||||
@click="deleteApiKey(key.id)"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 模型统计展开区域 -->
|
||||
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
||||
<td colspan="7" class="px-6 py-4 bg-gray-50">
|
||||
<div v-if="!apiKeyModelStats[key.id]" class="text-center py-4">
|
||||
<div class="loading-spinner mx-auto"></div>
|
||||
<p class="text-sm text-gray-500 mt-2">加载模型统计...</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- 通用的标题和时间筛选器,无论是否有数据都显示 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h5 class="text-sm font-semibold text-gray-700 flex items-center">
|
||||
<i class="fas fa-chart-pie text-indigo-500 mr-2"></i>
|
||||
模型使用分布
|
||||
</h5>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
|
||||
{{ apiKeyModelStats[key.id].length }} 个模型
|
||||
</span>
|
||||
|
||||
<!-- API Keys日期筛选器 -->
|
||||
<div class="flex gap-1 items-center">
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded p-1">
|
||||
<button
|
||||
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
|
||||
:key="option.value"
|
||||
@click="setApiKeyDateFilterPreset(option.value, key.id)"
|
||||
:class="[
|
||||
'px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
getApiKeyDateFilter(key.id).preset === option.value && getApiKeyDateFilter(key.id).type === 'preset'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Element Plus 日期范围选择器 -->
|
||||
<el-date-picker
|
||||
:model-value="getApiKeyDateFilter(key.id).customRange"
|
||||
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disabledDate"
|
||||
:default-time="defaultTime"
|
||||
size="small"
|
||||
style="width: 280px;"
|
||||
class="api-key-date-picker"
|
||||
:clearable="true"
|
||||
:unlink-panels="false"
|
||||
></el-date-picker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0" class="text-center py-8">
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<i class="fas fa-chart-line text-gray-400 text-lg"></i>
|
||||
<p class="text-sm text-gray-500">暂无模型使用数据</p>
|
||||
<button
|
||||
@click="resetApiKeyDateFilter(key.id)"
|
||||
class="text-blue-500 hover:text-blue-700 text-sm ml-2 flex items-center gap-1 transition-colors"
|
||||
title="重置筛选条件并刷新"
|
||||
>
|
||||
<i class="fas fa-sync-alt text-xs"></i>
|
||||
<span class="text-xs">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p>
|
||||
</div>
|
||||
<div v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="stat in apiKeyModelStats[key.id]" :key="stat.model"
|
||||
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-semibold text-gray-800 block mb-1">{{ stat.model }}</span>
|
||||
<span class="text-xs text-gray-500 bg-blue-50 px-2 py-1 rounded-full">{{ stat.requests }} 次请求</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-coins text-yellow-500 mr-1 text-xs"></i>
|
||||
总Token:
|
||||
</span>
|
||||
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs"></i>
|
||||
费用:
|
||||
</span>
|
||||
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
|
||||
</div>
|
||||
<div class="pt-2 mt-2 border-t border-gray-100">
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-arrow-down text-green-500 mr-1"></i>
|
||||
输入:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-arrow-up text-blue-500 mr-1"></i>
|
||||
输出:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
|
||||
</div>
|
||||
<div v-if="stat.cacheCreateTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-save mr-1"></i>
|
||||
缓存创建:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
|
||||
</div>
|
||||
<div v-if="stat.cacheReadTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
缓存读取:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-3">
|
||||
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
|
||||
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right mt-1">
|
||||
<span class="text-xs font-medium text-indigo-600">
|
||||
{{ calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总计统计,仅在有数据时显示 -->
|
||||
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-semibold text-gray-700 flex items-center">
|
||||
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
|
||||
总计统计
|
||||
</span>
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-gray-600">
|
||||
总请求: <span class="font-semibold text-gray-800">{{ apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) }}</span>
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
总Token: <span class="font-semibold text-gray-800">{{ formatNumber(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<CreateApiKeyModal
|
||||
v-if="showCreateApiKeyModal"
|
||||
:accounts="accounts"
|
||||
@close="showCreateApiKeyModal = false"
|
||||
@success="handleCreateSuccess"
|
||||
/>
|
||||
|
||||
<EditApiKeyModal
|
||||
v-if="showEditApiKeyModal"
|
||||
:apiKey="editingApiKey"
|
||||
:accounts="accounts"
|
||||
@close="showEditApiKeyModal = false"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
|
||||
<RenewApiKeyModal
|
||||
v-if="showRenewApiKeyModal"
|
||||
:apiKey="renewingApiKey"
|
||||
@close="showRenewApiKeyModal = false"
|
||||
@success="handleRenewSuccess"
|
||||
/>
|
||||
|
||||
<NewApiKeyModal
|
||||
v-if="showNewApiKeyModal"
|
||||
:apiKeyData="newApiKeyData"
|
||||
@close="showNewApiKeyModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
||||
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
||||
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||
|
||||
// 响应式数据
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeys = ref([])
|
||||
const apiKeysLoading = ref(false)
|
||||
const apiKeyStatsTimeRange = ref('today')
|
||||
const apiKeysSortBy = ref('')
|
||||
const apiKeysSortOrder = ref('asc')
|
||||
const expandedApiKeys = ref({})
|
||||
const apiKeyModelStats = ref({})
|
||||
const apiKeyDateFilters = ref({})
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
const accounts = ref({ claude: [], gemini: [] })
|
||||
|
||||
// 模态框状态
|
||||
const showCreateApiKeyModal = ref(false)
|
||||
const showEditApiKeyModal = ref(false)
|
||||
const showRenewApiKeyModal = ref(false)
|
||||
const showNewApiKeyModal = ref(false)
|
||||
const editingApiKey = ref(null)
|
||||
const renewingApiKey = ref(null)
|
||||
const newApiKeyData = ref(null)
|
||||
|
||||
// 计算排序后的API Keys
|
||||
const sortedApiKeys = computed(() => {
|
||||
if (!apiKeysSortBy.value) return apiKeys.value
|
||||
|
||||
const sorted = [...apiKeys.value].sort((a, b) => {
|
||||
let aVal = a[apiKeysSortBy.value]
|
||||
let bVal = b[apiKeysSortBy.value]
|
||||
|
||||
// 处理特殊排序字段
|
||||
if (apiKeysSortBy.value === 'status') {
|
||||
aVal = a.isActive ? 1 : 0
|
||||
bVal = b.isActive ? 1 : 0
|
||||
} else if (apiKeysSortBy.value === 'cost') {
|
||||
aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
|
||||
bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
|
||||
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
|
||||
aVal = aVal ? new Date(aVal).getTime() : 0
|
||||
bVal = bVal ? new Date(bVal).getTime() : 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, geminiData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
])
|
||||
|
||||
if (claudeData.success) {
|
||||
accounts.value.claude = claudeData.data || []
|
||||
}
|
||||
|
||||
if (geminiData.success) {
|
||||
accounts.value.gemini = geminiData.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载API Keys
|
||||
const loadApiKeys = async () => {
|
||||
apiKeysLoading.value = true
|
||||
try {
|
||||
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
|
||||
if (data.success) {
|
||||
apiKeys.value = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载 API Keys 失败', 'error')
|
||||
} finally {
|
||||
apiKeysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
if (apiKeysSortBy.value === field) {
|
||||
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
apiKeysSortBy.value = field
|
||||
apiKeysSortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算API Key费用
|
||||
const calculateApiKeyCost = (usage) => {
|
||||
if (!usage || !usage.total) return '$0.0000'
|
||||
const cost = usage.total.cost || 0
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
|
||||
// 获取绑定账户名称
|
||||
const getBoundAccountName = (accountId) => {
|
||||
if (!accountId) return '未知账户'
|
||||
|
||||
// 从Claude账户列表中查找
|
||||
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
|
||||
if (claudeAccount) {
|
||||
return claudeAccount.name
|
||||
}
|
||||
|
||||
// 从Gemini账户列表中查找
|
||||
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
|
||||
if (geminiAccount) {
|
||||
return geminiAccount.name
|
||||
}
|
||||
|
||||
// 如果找不到,返回账户ID的前8位
|
||||
return `账户-${accountId.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// 检查API Key是否过期
|
||||
const isApiKeyExpired = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
// 检查API Key是否即将过期
|
||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||
if (!expiresAt || isApiKeyExpired(expiresAt)) return false
|
||||
const daysUntilExpiry = (new Date(expiresAt) - new Date()) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpiry <= 7
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 切换模型统计展开状态
|
||||
const toggleApiKeyModelStats = async (keyId) => {
|
||||
if (!expandedApiKeys.value[keyId]) {
|
||||
expandedApiKeys.value[keyId] = true
|
||||
// 初始化日期筛选器
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
// 加载模型统计数据
|
||||
await loadApiKeyModelStats(keyId, true)
|
||||
} else {
|
||||
expandedApiKeys.value[keyId] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 API Key 的模型统计
|
||||
const loadApiKeyModelStats = async (keyId, forceReload = false) => {
|
||||
if (!forceReload && apiKeyModelStats.value[keyId] && apiKeyModelStats.value[keyId].length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
try {
|
||||
let url = `/admin/api-keys/${keyId}/model-stats`
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filter.customStart && filter.customEnd) {
|
||||
params.append('startDate', filter.customStart)
|
||||
params.append('endDate', filter.customEnd)
|
||||
params.append('period', 'custom')
|
||||
} else {
|
||||
const period = filter.preset === 'today' ? 'daily' : 'monthly'
|
||||
params.append('period', period)
|
||||
}
|
||||
|
||||
url += '?' + params.toString()
|
||||
|
||||
const data = await apiClient.get(url)
|
||||
if (data.success) {
|
||||
apiKeyModelStats.value[keyId] = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载模型统计失败', 'error')
|
||||
apiKeyModelStats.value[keyId] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 计算API Key模型使用百分比
|
||||
const calculateApiKeyModelPercentage = (value, stats) => {
|
||||
const total = stats.reduce((sum, stat) => sum + (stat.allTokens || 0), 0)
|
||||
if (total === 0) return 0
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
// 计算单个模型费用
|
||||
const calculateModelCost = (stat) => {
|
||||
// 优先使用后端返回的费用数据
|
||||
if (stat.formatted && stat.formatted.total) {
|
||||
return stat.formatted.total
|
||||
}
|
||||
|
||||
// 如果没有 formatted 数据,尝试使用 cost 字段
|
||||
if (stat.cost !== undefined) {
|
||||
return `$${stat.cost.toFixed(6)}`
|
||||
}
|
||||
|
||||
// 默认返回
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
// 初始化API Key的日期筛选器
|
||||
const initApiKeyDateFilter = (keyId) => {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6) // 7天前
|
||||
|
||||
apiKeyDateFilters.value[keyId] = {
|
||||
type: 'preset',
|
||||
preset: '7days',
|
||||
customStart: startDate.toISOString().split('T')[0],
|
||||
customEnd: today.toISOString().split('T')[0],
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API Key的日期筛选器状态
|
||||
const getApiKeyDateFilter = (keyId) => {
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
return apiKeyDateFilters.value[keyId]
|
||||
}
|
||||
|
||||
// 设置 API Key 日期预设
|
||||
const setApiKeyDateFilterPreset = (preset, keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
filter.type = 'preset'
|
||||
filter.preset = preset
|
||||
|
||||
const option = filter.presetOptions.find(opt => opt.value === preset)
|
||||
if (option) {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - (option.days - 1))
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' 00:00:00'
|
||||
}
|
||||
|
||||
filter.customRange = [
|
||||
formatDate(startDate),
|
||||
formatDate(today)
|
||||
]
|
||||
}
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
}
|
||||
|
||||
// API Key 自定义日期范围变化
|
||||
const onApiKeyCustomDateRangeChange = (keyId, value) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
if (value && value.length === 2) {
|
||||
filter.type = 'custom'
|
||||
filter.preset = ''
|
||||
filter.customRange = value
|
||||
filter.customStart = value[0].split(' ')[0]
|
||||
filter.customEnd = value[1].split(' ')[0]
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认7天
|
||||
setApiKeyDateFilterPreset('7days', keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用未来日期
|
||||
const disabledDate = (date) => {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
// 重置API Key日期筛选器
|
||||
const resetApiKeyDateFilter = (keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
// 重置为默认的7天
|
||||
filter.type = 'preset'
|
||||
filter.preset = '7days'
|
||||
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6)
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
filter.customRange = null
|
||||
|
||||
// 重新加载数据
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
showToast('已重置筛选条件并刷新数据', 'info')
|
||||
}
|
||||
|
||||
// 打开创建模态框
|
||||
const openCreateApiKeyModal = () => {
|
||||
showCreateApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
const openEditApiKeyModal = (apiKey) => {
|
||||
editingApiKey.value = apiKey
|
||||
showEditApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开续期模态框
|
||||
const openRenewApiKeyModal = (apiKey) => {
|
||||
renewingApiKey.value = apiKey
|
||||
showRenewApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 处理创建成功
|
||||
const handleCreateSuccess = (data) => {
|
||||
showCreateApiKeyModal.value = false
|
||||
newApiKeyData.value = data
|
||||
showNewApiKeyModal.value = true
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理编辑成功
|
||||
const handleEditSuccess = () => {
|
||||
showEditApiKeyModal.value = false
|
||||
showToast('API Key 更新成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理续期成功
|
||||
const handleRenewSuccess = () => {
|
||||
showRenewApiKeyModal.value = false
|
||||
showToast('API Key 续期成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('确定要删除这个 API Key 吗?此操作不可恢复。')) return
|
||||
|
||||
try {
|
||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
||||
if (data.success) {
|
||||
showToast('API Key 已删除', 'success')
|
||||
loadApiKeys()
|
||||
} else {
|
||||
showToast(data.message || '删除失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制API统计页面链接
|
||||
const copyApiStatsLink = (apiKey) => {
|
||||
// 构建统计页面的完整URL
|
||||
const baseUrl = window.location.origin
|
||||
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
|
||||
|
||||
// 使用传统的textarea方法复制到剪贴板
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = statsUrl
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, 99999) // 兼容移动端
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showToast(`已复制统计页面链接`, 'success')
|
||||
} else {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.error('复制错误:', err)
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载所有需要的数据
|
||||
await Promise.all([
|
||||
clientsStore.loadSupportedClients(),
|
||||
loadAccounts(),
|
||||
loadApiKeys()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-input__inner) {
|
||||
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-range-separator) {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
</style>
|
||||
384
web/admin-spa/src/views/ApiStatsView.vue
Normal file
384
web/admin-spa/src/views/ApiStatsView.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="min-h-screen gradient-bg p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link to="/dashboard" class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-sm"></i>
|
||||
<span class="text-sm font-medium">管理后台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20">
|
||||
<button
|
||||
@click="currentTab = 'stats'"
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'stats' ? 'active' : ''
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-chart-line mr-2"></i>
|
||||
<span>统计查询</span>
|
||||
</button>
|
||||
<button
|
||||
@click="currentTab = 'tutorial'"
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'tutorial' ? 'active' : ''
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-graduation-cap mr-2"></i>
|
||||
<span>使用教程</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计内容 -->
|
||||
<div v-if="currentTab === 'stats'" class="tab-content">
|
||||
<!-- API Key 输入区域 -->
|
||||
<ApiKeyInput />
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="mb-8">
|
||||
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据展示区域 -->
|
||||
<div v-if="statsData" class="fade-in">
|
||||
<div class="glass-strong rounded-3xl p-6 shadow-xl">
|
||||
<!-- 时间范围选择器 -->
|
||||
<div class="mb-6 pb-6 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-clock text-blue-500 text-lg"></i>
|
||||
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="switchPeriod('daily')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
@click="switchPeriod('monthly')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息和统计概览 -->
|
||||
<StatsOverview />
|
||||
|
||||
<!-- Token 分布和限制配置 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<TokenDistribution />
|
||||
<LimitConfig />
|
||||
</div>
|
||||
|
||||
<!-- 模型使用统计 -->
|
||||
<ModelUsageStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 教程内容 -->
|
||||
<div v-if="currentTab === 'tutorial'" class="tab-content">
|
||||
<div class="glass-strong rounded-3xl shadow-xl">
|
||||
<TutorialView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
||||
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
||||
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
||||
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||
import TutorialView from './TutorialView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
|
||||
// 当前标签页
|
||||
const currentTab = ref('stats')
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
apiId,
|
||||
loading,
|
||||
modelStatsLoading,
|
||||
oemLoading,
|
||||
error,
|
||||
statsPeriod,
|
||||
statsData,
|
||||
oemSettings
|
||||
} = storeToRefs(apiStatsStore)
|
||||
|
||||
const {
|
||||
queryStats,
|
||||
switchPeriod,
|
||||
loadStatsWithApiId,
|
||||
loadOemSettings,
|
||||
reset
|
||||
} = apiStatsStore
|
||||
|
||||
// 处理键盘快捷键
|
||||
const handleKeyDown = (event) => {
|
||||
// Ctrl/Cmd + Enter 查询
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
if (!loading.value && apiKey.value.trim()) {
|
||||
queryStats()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// ESC 清除数据
|
||||
if (event.key === 'Escape') {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
console.log('API Stats Page loaded')
|
||||
|
||||
// 加载 OEM 设置
|
||||
loadOemSettings()
|
||||
|
||||
// 检查 URL 参数
|
||||
const urlApiId = route.query.apiId
|
||||
const urlApiKey = route.query.apiKey
|
||||
|
||||
if (urlApiId && urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||
apiId.value = urlApiId
|
||||
loadStatsWithApiId()
|
||||
} else if (urlApiKey && urlApiKey.length > 10) {
|
||||
// 向后兼容,支持 apiKey 参数
|
||||
apiKey.value = urlApiKey
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 监听 API Key 变化
|
||||
watch(apiKey, (newValue) => {
|
||||
if (!newValue) {
|
||||
apiStatsStore.clearData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 渐变背景 */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gradient-bg::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 标题渐变 */
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* 管理后台按钮 */
|
||||
.admin-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.3), 0 2px 4px -1px rgba(102, 126, 234, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.admin-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(102, 126, 234, 0.4), 0 4px 6px -2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.admin-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* 时间范围按钮 */
|
||||
.period-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.period-btn:not(.active) {
|
||||
color: #374151;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.period-btn:not(.active):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Tab 胶囊按钮样式 */
|
||||
.tab-pill-button {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-pill-button:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab-pill-button.active {
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tab-pill-button i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Tab 内容切换动画 */
|
||||
.tab-content {
|
||||
animation: tabFadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
744
web/admin-spa/src/views/DashboardView.vue
Normal file
744
web/admin-spa/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,744 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 主要统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">总API Keys</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
活跃: {{ dashboardData.activeAccounts || 0 }}
|
||||
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
|
||||
| 限流: {{ dashboardData.rateLimitedAccounts }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">今日请求</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">系统状态</p>
|
||||
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formattedUptime }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 mr-8">
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
|
||||
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
|
||||
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="flex justify-between items-center flex-wrap gap-x-4">
|
||||
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.todayInputTokens || 0) }}</span></span>
|
||||
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.todayOutputTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
|
||||
<i class="fas fa-coins"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 mr-8">
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
|
||||
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
|
||||
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="flex justify-between items-center flex-wrap gap-x-4">
|
||||
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.totalInputTokens || 0) }}</span></span>
|
||||
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.totalOutputTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">平均RPM</p>
|
||||
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟请求数</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">平均TPM</p>
|
||||
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟Token数</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
|
||||
<i class="fas fa-rocket"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型消费统计 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">模型使用分布与Token使用趋势</h3>
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
v-for="option in dateFilter.presetOptions"
|
||||
:key="option.value"
|
||||
@click="setDateFilterPreset(option.value)"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
dateFilter.preset === option.value && dateFilter.type === 'preset'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换按钮 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
@click="setTrendGranularity('day')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'day'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-calendar-day mr-1"></i>按天
|
||||
</button>
|
||||
<button
|
||||
@click="setTrendGranularity('hour')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'hour'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>按小时
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Element Plus 日期范围选择器 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<el-date-picker
|
||||
:default-time="defaultTime"
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
style="width: 400px;"
|
||||
class="custom-date-picker"
|
||||
></el-date-picker>
|
||||
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
|
||||
<i class="fas fa-info-circle"></i> 最多24小时
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-sync-alt"></i>刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 饼图 -->
|
||||
<div class="card p-6">
|
||||
<h4 class="text-lg font-semibold text-gray-800 mb-4">Token使用分布</h4>
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="modelUsageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细数据表格 -->
|
||||
<div class="card p-6">
|
||||
<h4 class="text-lg font-semibold text-gray-800 mb-4">详细统计数据</h4>
|
||||
<div v-if="dashboardModelStats.length === 0" class="text-center py-8">
|
||||
<p class="text-gray-500">暂无模型使用数据</p>
|
||||
</div>
|
||||
<div v-else class="overflow-auto max-h-[300px]">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">模型</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">请求数</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">总Token</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">费用</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-sm text-gray-900">{{ stat.model }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.requests) }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.allTokens) }}</td>
|
||||
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">{{ stat.formatted ? stat.formatted.total : '$0.000000' }}</td>
|
||||
<td class="px-4 py-2 text-sm font-medium text-right">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token使用趋势图 -->
|
||||
<div class="mb-8">
|
||||
<div class="card p-6">
|
||||
<div style="height: 300px;">
|
||||
<canvas ref="usageTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys 使用趋势图 -->
|
||||
<div class="mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
|
||||
<!-- 维度切换按钮 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
apiKeysTrendMetric === 'requests'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-exchange-alt mr-1"></i>请求次数
|
||||
</button>
|
||||
<button
|
||||
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
apiKeysTrendMetric === 'tokens'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-coins mr-1"></i>Token 数量
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
||||
</span>
|
||||
<span v-else>
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 350px;">
|
||||
<canvas ref="apiKeysUsageTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const {
|
||||
dashboardData,
|
||||
costsData,
|
||||
dashboardModelStats,
|
||||
trendData,
|
||||
apiKeysTrendData,
|
||||
formattedUptime,
|
||||
dateFilter,
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
defaultTime
|
||||
} = storeToRefs(dashboardStore)
|
||||
|
||||
const {
|
||||
loadDashboardData,
|
||||
loadUsageTrend,
|
||||
loadModelStats,
|
||||
loadApiKeysTrend,
|
||||
setDateFilterPreset,
|
||||
onCustomDateRangeChange,
|
||||
setTrendGranularity,
|
||||
refreshChartsData,
|
||||
disabledDate
|
||||
} = dashboardStore
|
||||
|
||||
// Chart 实例
|
||||
const modelUsageChart = ref(null)
|
||||
const usageTrendChart = ref(null)
|
||||
const apiKeysUsageTrendChart = ref(null)
|
||||
let modelUsageChartInstance = null
|
||||
let usageTrendChartInstance = null
|
||||
let apiKeysUsageTrendChartInstance = null
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
function calculatePercentage(value, stats) {
|
||||
if (!stats || stats.length === 0) return 0
|
||||
const total = stats.reduce((sum, stat) => sum + stat.allTokens, 0)
|
||||
if (total === 0) return 0
|
||||
return ((value / total) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
// 创建模型使用饼图
|
||||
function createModelUsageChart() {
|
||||
if (!modelUsageChart.value) return
|
||||
|
||||
if (modelUsageChartInstance) {
|
||||
modelUsageChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = dashboardModelStats.value || []
|
||||
const chartData = {
|
||||
labels: data.map(d => d.model),
|
||||
datasets: [{
|
||||
data: data.map(d => d.allTokens),
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||
],
|
||||
borderWidth: 0
|
||||
}]
|
||||
}
|
||||
|
||||
modelUsageChartInstance = new Chart(modelUsageChart.value, {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 15,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label || ''
|
||||
const value = formatNumber(context.parsed)
|
||||
const percentage = calculatePercentage(context.parsed, data)
|
||||
return `${label}: ${value} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建使用趋势图
|
||||
function createUsageTrendChart() {
|
||||
if (!usageTrendChart.value) return
|
||||
|
||||
if (usageTrendChartInstance) {
|
||||
usageTrendChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = trendData.value || []
|
||||
|
||||
// 准备多维度数据
|
||||
const inputData = data.map(d => d.inputTokens || 0)
|
||||
const outputData = data.map(d => d.outputTokens || 0)
|
||||
const cacheCreateData = data.map(d => d.cacheCreateTokens || 0)
|
||||
const cacheReadData = data.map(d => d.cacheReadTokens || 0)
|
||||
const requestsData = data.map(d => d.requests || 0)
|
||||
const costData = data.map(d => d.cost || 0)
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: '输入Token',
|
||||
data: inputData,
|
||||
borderColor: 'rgb(102, 126, 234)',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '输出Token',
|
||||
data: outputData,
|
||||
borderColor: 'rgb(240, 147, 251)',
|
||||
backgroundColor: 'rgba(240, 147, 251, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '缓存创建Token',
|
||||
data: cacheCreateData,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '缓存读取Token',
|
||||
data: cacheReadData,
|
||||
borderColor: 'rgb(147, 51, 234)',
|
||||
backgroundColor: 'rgba(147, 51, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '费用 (USD)',
|
||||
data: costData,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y2'
|
||||
},
|
||||
{
|
||||
label: '请求数',
|
||||
data: requestsData,
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
usageTrendChartInstance = new Chart(usageTrendChart.value, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token使用趋势',
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || ''
|
||||
let value = context.parsed.y
|
||||
|
||||
if (label === '费用 (USD)') {
|
||||
// 格式化费用显示
|
||||
if (value < 0.01) {
|
||||
return label + ': $' + value.toFixed(6)
|
||||
} else {
|
||||
return label + ': $' + value.toFixed(4)
|
||||
}
|
||||
} else if (label === '请求数') {
|
||||
return label + ': ' + value.toLocaleString() + ' 次'
|
||||
} else {
|
||||
return label + ': ' + value.toLocaleString() + ' tokens'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity.value === 'hour' ? '时间' : '日期'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token数量'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return formatNumber(value)
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false, // 隐藏费用轴,在tooltip中显示
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建API Keys使用趋势图
|
||||
function createApiKeysUsageTrendChart() {
|
||||
if (!apiKeysUsageTrendChart.value) return
|
||||
|
||||
if (apiKeysUsageTrendChartInstance) {
|
||||
apiKeysUsageTrendChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = apiKeysTrendData.value.data || []
|
||||
const metric = apiKeysTrendMetric.value
|
||||
|
||||
// 颜色数组
|
||||
const colors = [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||
]
|
||||
|
||||
// 准备数据集
|
||||
const datasets = apiKeysTrendData.value.topApiKeys?.map((apiKeyId, index) => {
|
||||
const data = apiKeysTrendData.value.data.map(item => {
|
||||
if (!item.apiKeys || !item.apiKeys[apiKeyId]) return 0
|
||||
return metric === 'tokens'
|
||||
? item.apiKeys[apiKeyId].tokens
|
||||
: item.apiKeys[apiKeyId].requests || 0
|
||||
})
|
||||
|
||||
// 获取API Key名称
|
||||
const apiKeyName = apiKeysTrendData.value.data.find(item =>
|
||||
item.apiKeys && item.apiKeys[apiKeyId]
|
||||
)?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`
|
||||
|
||||
return {
|
||||
label: apiKeyName,
|
||||
data: data,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length] + '20',
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}
|
||||
}) || []
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: datasets
|
||||
}
|
||||
|
||||
apiKeysUsageTrendChartInstance = new Chart(apiKeysUsageTrendChart.value, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.parsed.y
|
||||
const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次'
|
||||
return label + ': ' + value.toLocaleString() + unit
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity.value === 'hour' ? '时间' : '日期'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return formatNumber(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新API Keys使用趋势图
|
||||
async function updateApiKeysUsageTrendChart() {
|
||||
await loadApiKeysTrend(apiKeysTrendMetric.value)
|
||||
await nextTick()
|
||||
createApiKeysUsageTrendChart()
|
||||
}
|
||||
|
||||
// 监听数据变化更新图表
|
||||
watch(dashboardModelStats, () => {
|
||||
nextTick(() => createModelUsageChart())
|
||||
})
|
||||
|
||||
watch(trendData, () => {
|
||||
nextTick(() => createUsageTrendChart())
|
||||
})
|
||||
|
||||
watch(apiKeysTrendData, () => {
|
||||
nextTick(() => createApiKeysUsageTrendChart())
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 加载所有数据
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
refreshChartsData() // 使用refreshChartsData来确保根据当前筛选条件加载数据
|
||||
])
|
||||
|
||||
// 创建图表
|
||||
await nextTick()
|
||||
createModelUsageChart()
|
||||
createUsageTrendChart()
|
||||
createApiKeysUsageTrendChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义日期选择器样式 */
|
||||
.custom-date-picker :deep(.el-input__inner) {
|
||||
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.custom-date-picker :deep(.el-range-separator) {
|
||||
@apply text-gray-500;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.custom-date-picker :deep(.el-range-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
90
web/admin-spa/src/views/LoginView.vue
Normal file
90
web/admin-spa/src/views/LoginView.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-6">
|
||||
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
|
||||
<div class="text-center mb-8">
|
||||
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
|
||||
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
|
||||
<template v-if="!oemLoading">
|
||||
<img v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
alt="Logo"
|
||||
class="w-12 h-12 object-contain"
|
||||
@error="(e) => e.target.style.display = 'none'">
|
||||
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
|
||||
</template>
|
||||
<div v-else class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<template v-if="!oemLoading && authStore.oemSettings.siteName">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ authStore.oemSettings.siteName }}</h1>
|
||||
</template>
|
||||
<div v-else-if="oemLoading" class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"></div>
|
||||
<p class="text-gray-600 text-lg">管理后台</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-3">用户名</label>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-3">密码</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.loginLoading"
|
||||
class="btn btn-primary w-full py-4 px-6 text-lg font-semibold"
|
||||
>
|
||||
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2"></i>
|
||||
<div v-if="authStore.loginLoading" class="loading-spinner mr-2"></div>
|
||||
{{ authStore.loginLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="authStore.loginError" class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>{{ authStore.loginError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 加载OEM设置
|
||||
authStore.loadOemSettings()
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
await authStore.login(loginForm.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式已经在全局样式中定义 */
|
||||
</style>
|
||||
279
web/admin-spa/src/views/SettingsView.vue
Normal file
279
web/admin-spa/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
|
||||
<p class="text-gray-600">自定义网站名称和图标</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">正在加载设置...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="min-w-full">
|
||||
<tbody class="divide-y divide-gray-200/50">
|
||||
<!-- 网站名称 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-font text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站名称</div>
|
||||
<div class="text-xs text-gray-500">品牌标识</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<input
|
||||
v-model="oemSettings.siteName"
|
||||
type="text"
|
||||
class="form-input w-full max-w-md"
|
||||
placeholder="Claude Relay Service"
|
||||
maxlength="100"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 网站图标 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-image text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站图标</div>
|
||||
<div class="text-xs text-gray-500">Favicon</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
<!-- 图标预览 -->
|
||||
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
alt="图标预览"
|
||||
class="w-8 h-8"
|
||||
@error="handleIconError"
|
||||
>
|
||||
<span class="text-sm text-gray-600">当前图标</span>
|
||||
<button
|
||||
@click="removeIcon"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref="iconFileInput"
|
||||
@change="handleIconUpload"
|
||||
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||
class="hidden"
|
||||
>
|
||||
<button
|
||||
@click="$refs.iconFileInput.click()"
|
||||
class="btn btn-success px-4 py-2"
|
||||
>
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
上传图标
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<tr>
|
||||
<td class="px-6 py-6" colspan="2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="saveOemSettings"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': saving }"
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="resetOemSettings"
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
重置为默认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
// 使用settings store
|
||||
const settingsStore = useSettingsStore()
|
||||
const { loading, saving, oemSettings } = storeToRefs(settingsStore)
|
||||
|
||||
// 组件refs
|
||||
const iconFileInput = ref()
|
||||
|
||||
// 页面加载时获取设置
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await settingsStore.loadOemSettings()
|
||||
} catch (error) {
|
||||
showToast('加载设置失败', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// 保存OEM设置
|
||||
const saveOemSettings = async () => {
|
||||
try {
|
||||
const settings = {
|
||||
siteName: oemSettings.value.siteName,
|
||||
siteIcon: oemSettings.value.siteIcon,
|
||||
siteIconData: oemSettings.value.siteIconData
|
||||
}
|
||||
const result = await settingsStore.saveOemSettings(settings)
|
||||
if (result && result.success) {
|
||||
showToast('OEM设置保存成功', 'success')
|
||||
} else {
|
||||
showToast(result?.message || '保存失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('保存OEM设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置OEM设置
|
||||
const resetOemSettings = async () => {
|
||||
if (!confirm('确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。')) return
|
||||
|
||||
try {
|
||||
const result = await settingsStore.resetOemSettings()
|
||||
if (result && result.success) {
|
||||
showToast('已重置为默认设置', 'success')
|
||||
} else {
|
||||
showToast('重置失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('重置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理图标上传
|
||||
const handleIconUpload = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件
|
||||
const validation = settingsStore.validateIconFile(file)
|
||||
if (!validation.isValid) {
|
||||
validation.errors.forEach(error => showToast(error, 'error'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 转换为Base64
|
||||
const base64Data = await settingsStore.fileToBase64(file)
|
||||
oemSettings.value.siteIconData = base64Data
|
||||
} catch (error) {
|
||||
showToast('文件读取失败', 'error')
|
||||
}
|
||||
|
||||
// 清除input的值,允许重复选择同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// 删除图标
|
||||
const removeIcon = () => {
|
||||
oemSettings.value.siteIcon = ''
|
||||
oemSettings.value.siteIconData = ''
|
||||
}
|
||||
|
||||
// 处理图标加载错误
|
||||
const handleIconError = () => {
|
||||
console.warn('Icon failed to load')
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = settingsStore.formatDateTime
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-5 h-5 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin;
|
||||
}
|
||||
</style>
|
||||
828
web/admin-spa/src/views/TutorialView.vue
Normal file
828
web/admin-spa/src/views/TutorialView.vue
Normal file
@@ -0,0 +1,828 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<i class="fas fa-graduation-cap text-blue-600 mr-3"></i>
|
||||
Claude Code 使用教程
|
||||
</h3>
|
||||
<p class="text-gray-600 text-lg">跟着这个教程,你可以轻松在自己的电脑上安装并使用 Claude Code。</p>
|
||||
</div>
|
||||
|
||||
<!-- 系统选择标签 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-wrap gap-2 p-2 bg-gray-100 rounded-xl">
|
||||
<button
|
||||
v-for="system in tutorialSystems"
|
||||
:key="system.key"
|
||||
@click="activeTutorialSystem = system.key"
|
||||
:class="['flex-1 py-3 px-6 text-sm font-semibold rounded-lg transition-all duration-300 flex items-center justify-center gap-2',
|
||||
activeTutorialSystem === system.key
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900']"
|
||||
>
|
||||
<i :class="system.icon"></i>
|
||||
{{ system.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Windows 教程 -->
|
||||
<div v-if="activeTutorialSystem === 'windows'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-windows text-blue-600 mr-2"></i>
|
||||
Windows 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:官网下载(推荐)</p>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
|
||||
<li>打开浏览器访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
|
||||
<li>点击 "LTS" 版本进行下载(推荐长期支持版本)</li>
|
||||
<li>下载完成后双击 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.msi</code> 文件</li>
|
||||
<li>按照安装向导完成安装,保持默认设置即可</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:使用包管理器</p>
|
||||
<p class="text-gray-600 mb-2">如果你安装了 Chocolatey 或 Scoop,可以使用命令行安装:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 使用 Chocolatey</div>
|
||||
<div class="text-gray-300">choco install nodejs</div>
|
||||
<div class="mt-3 mb-2"># 或使用 Scoop</div>
|
||||
<div class="text-gray-300">scoop install nodejs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-blue-800 mb-2">Windows 注意事项</h6>
|
||||
<ul class="text-blue-700 text-sm space-y-1">
|
||||
<li>• 建议使用 PowerShell 而不是 CMD</li>
|
||||
<li>• 如果遇到权限问题,尝试以管理员身份运行</li>
|
||||
<li>• 某些杀毒软件可能会误报,需要添加白名单</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开 PowerShell 或 CMD,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Git Bash -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Git Bash
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Windows 环境下需要使用 Git Bash 安装Claude code。安装完成后,环境变量设置和使用 Claude Code 仍然在普通的 PowerShell 或 CMD 中进行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl p-6 border border-green-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-git-alt text-green-600 mr-2"></i>
|
||||
下载并安装 Git for Windows
|
||||
</h5>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4 mb-4">
|
||||
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://git-scm.com/downloads/win</code></li>
|
||||
<li>点击 "Download for Windows" 下载安装包</li>
|
||||
<li>运行下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.exe</code> 安装文件</li>
|
||||
<li>在安装过程中保持默认设置,直接点击 "Next" 完成安装</li>
|
||||
</ol>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">安装完成后</h6>
|
||||
<ul class="text-green-700 text-sm space-y-1">
|
||||
<li>• 在任意文件夹右键可以看到 "Git Bash Here" 选项</li>
|
||||
<li>• 也可以从开始菜单启动 "Git Bash"</li>
|
||||
<li>• 只需要在 Git Bash 中运行 npm install 命令</li>
|
||||
<li>• 后续的环境变量设置和使用都在 PowerShell/CMD 中</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Git Bash 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">打开 Git Bash,输入以下命令验证:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">git --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示 Git 版本号,说明安装成功!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开 Git Bash(重要:不要使用 PowerShell),运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 在 Git Bash 中全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code。</p>
|
||||
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||||
<h6 class="font-medium text-yellow-800 mb-2">重要提醒</h6>
|
||||
<ul class="text-yellow-700 text-sm space-y-1">
|
||||
<li>• 必须在 Git Bash 中运行,不要在 PowerShell 中运行</li>
|
||||
<li>• 如果遇到权限问题,可以尝试在 Git Bash 中使用 sudo 命令</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:PowerShell 临时设置(推荐)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在 PowerShell 中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">$env:ANTHROPIC_BASE_URL = "{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">$env:ANTHROPIC_AUTH_TOKEN = "你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:系统环境变量(永久设置)</h6>
|
||||
<ol class="text-gray-600 text-sm space-y-1 list-decimal list-inside">
|
||||
<li>右键"此电脑" → "属性" → "高级系统设置"</li>
|
||||
<li>点击"环境变量"按钮</li>
|
||||
<li>在"用户变量"或"系统变量"中点击"新建"</li>
|
||||
<li>添加以下两个变量:</li>
|
||||
</ol>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||
<strong>变量名:</strong> ANTHROPIC_BASE_URL<br>
|
||||
<strong>变量值:</strong> <span class="font-mono">{{ currentBaseUrl }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||
<strong>变量名:</strong> ANTHROPIC_AUTH_TOKEN<br>
|
||||
<strong>变量值:</strong> <span class="font-mono">你的API密钥</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证环境变量设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
|
||||
<h6 class="font-medium text-blue-800 mb-2">验证环境变量设置</h6>
|
||||
<p class="text-blue-700 text-sm mb-3">设置完环境变量后,可以通过以下命令验证是否设置成功:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在 PowerShell 中验证:</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
|
||||
<div class="text-gray-300">echo $env:ANTHROPIC_BASE_URL</div>
|
||||
<div class="text-gray-300">echo $env:ANTHROPIC_AUTH_TOKEN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在 CMD 中验证:</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
|
||||
<div class="text-gray-300">echo %ANTHROPIC_BASE_URL%</div>
|
||||
<div class="text-gray-300">echo %ANTHROPIC_AUTH_TOKEN%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<p class="text-blue-700 text-sm">
|
||||
<strong>预期输出示例:</strong>
|
||||
</p>
|
||||
<div class="bg-gray-100 p-2 rounded text-sm font-mono">
|
||||
<div>{{ currentBaseUrl }}</div>
|
||||
<div>cr_xxxxxxxxxxxxxxxxxx</div>
|
||||
</div>
|
||||
<p class="text-blue-700 text-xs">
|
||||
💡 如果输出为空或显示变量名本身,说明环境变量设置失败,请重新设置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第五步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">5</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd C:\path\to\your\project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Windows 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
Windows 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示 "permission denied" 错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">这通常是权限问题,尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>以管理员身份运行 PowerShell</li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix %APPDATA%\npm</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
PowerShell 执行策略错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">如果遇到执行策略限制,运行:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量设置后不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">设置永久环境变量后需要:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>重新启动 PowerShell 或 CMD</li>
|
||||
<li>或者注销并重新登录 Windows</li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $env:ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS 教程 -->
|
||||
<div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-gray-50 to-slate-50 rounded-xl p-6 border border-gray-200 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-apple text-gray-700 mr-2"></i>
|
||||
macOS 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:使用 Homebrew(推荐)</p>
|
||||
<p class="text-gray-600 mb-2">如果你已经安装了 Homebrew,使用它安装 Node.js 会更方便:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 更新 Homebrew</div>
|
||||
<div class="text-gray-300">brew update</div>
|
||||
<div class="mt-3 mb-2"># 安装 Node.js</div>
|
||||
<div class="text-gray-300">brew install node</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:官网下载</p>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
|
||||
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
|
||||
<li>下载适合 macOS 的 LTS 版本</li>
|
||||
<li>打开下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.pkg</code> 文件</li>
|
||||
<li>按照安装程序指引完成安装</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-gray-800 mb-2">macOS 注意事项</h6>
|
||||
<ul class="text-gray-700 text-sm space-y-1">
|
||||
<li>• 如果遇到权限问题,可能需要使用 <code class="bg-gray-200 px-1 rounded">sudo</code></li>
|
||||
<li>• 首次运行可能需要在系统偏好设置中允许</li>
|
||||
<li>• 建议使用 Terminal 或 iTerm2</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开 Terminal,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开 Terminal,运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题,可以使用 sudo:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:临时设置(当前会话)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在 Terminal 中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:永久设置</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件(根据你使用的 shell):</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
|
||||
<div class="mb-2"># 对于 zsh (默认)</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">source ~/.zshrc</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 对于 bash</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bash_profile</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bash_profile</div>
|
||||
<div class="text-gray-300">source ~/.bash_profile</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd /path/to/your/project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
macOS 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示权限错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>使用 sudo 安装:<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
macOS 安全设置阻止运行
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">如果系统阻止运行 Claude Code:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>打开"系统偏好设置" → "安全性与隐私"</li>
|
||||
<li>点击"仍要打开"或"允许"</li>
|
||||
<li>或者在 Terminal 中运行:<code class="bg-gray-200 px-1 rounded">sudo spctl --master-disable</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">检查以下几点:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>确认修改了正确的配置文件(.zshrc 或 .bash_profile)</li>
|
||||
<li>重新启动 Terminal</li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linux 教程 -->
|
||||
<div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-red-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-ubuntu text-orange-600 mr-2"></i>
|
||||
Linux 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:使用官方仓库(推荐)</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 添加 NodeSource 仓库</div>
|
||||
<div class="text-gray-300">curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -</div>
|
||||
<div class="mt-3 mb-2"># 安装 Node.js</div>
|
||||
<div class="text-gray-300">sudo apt-get install -y nodejs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:使用系统包管理器</p>
|
||||
<p class="text-gray-600 mb-2">虽然版本可能不是最新的,但对于基本使用已经足够:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># Ubuntu/Debian</div>
|
||||
<div class="text-gray-300">sudo apt update</div>
|
||||
<div class="text-gray-300">sudo apt install nodejs npm</div>
|
||||
<div class="mt-3 mb-2"># CentOS/RHEL/Fedora</div>
|
||||
<div class="text-gray-300">sudo dnf install nodejs npm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-orange-800 mb-2">Linux 注意事项</h6>
|
||||
<ul class="text-orange-700 text-sm space-y-1">
|
||||
<li>• 某些发行版可能需要安装额外的依赖</li>
|
||||
<li>• 如果遇到权限问题,使用 <code class="bg-orange-200 px-1 rounded">sudo</code></li>
|
||||
<li>• 确保你的用户在 npm 的全局目录有写权限</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开终端,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开终端,运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题,可以使用 sudo:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:临时设置(当前会话)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在终端中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:永久设置</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
|
||||
<div class="mb-2"># 对于 bash (默认)</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bashrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bashrc</div>
|
||||
<div class="text-gray-300">source ~/.bashrc</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 对于 zsh</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">source ~/.zshrc</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd /path/to/your/project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linux 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
Linux 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示权限错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>使用 sudo 安装:<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
|
||||
<li>然后添加到 PATH:<code class="bg-gray-200 px-1 rounded">export PATH=~/.npm-global/bin:$PATH</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
缺少依赖库
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">某些 Linux 发行版需要安装额外依赖:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># Ubuntu/Debian</div>
|
||||
<div class="text-gray-300">sudo apt install build-essential</div>
|
||||
<div class="mt-2 mb-2"># CentOS/RHEL</div>
|
||||
<div class="text-gray-300">sudo dnf groupinstall "Development Tools"</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">检查以下几点:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>确认修改了正确的配置文件(.bashrc 或 .zshrc)</li>
|
||||
<li>重新启动终端或运行 <code class="bg-gray-200 px-1 rounded">source ~/.bashrc</code></li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结尾 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl p-6 text-center">
|
||||
<h5 class="text-xl font-semibold mb-2">🎉 恭喜你!</h5>
|
||||
<p class="text-blue-100 mb-4">你已经成功安装并配置了 Claude Code,现在可以开始享受 AI 编程助手带来的便利了。</p>
|
||||
<p class="text-sm text-blue-200">如果在使用过程中遇到任何问题,可以查看官方文档或社区讨论获取帮助。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 当前系统选择
|
||||
const activeTutorialSystem = ref('windows')
|
||||
|
||||
// 系统列表
|
||||
const tutorialSystems = [
|
||||
{ key: 'windows', name: 'Windows', icon: 'fab fa-windows' },
|
||||
{ key: 'macos', name: 'macOS', icon: 'fab fa-apple' },
|
||||
{ key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' },
|
||||
]
|
||||
|
||||
// 当前域名
|
||||
const currentDomain = computed(() => {
|
||||
return window.location.origin
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.tutorial-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.tutorial-content h4 {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.tutorial-content .bg-gradient-to-r {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-content .bg-gradient-to-r:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
38
web/admin-spa/tailwind.config.js
Normal file
38
web/admin-spa/tailwind.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'gradient': 'gradient 8s ease infinite',
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'float-delayed': 'float 6s ease-in-out infinite 2s',
|
||||
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
gradient: {
|
||||
'0%, 100%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'left center'
|
||||
},
|
||||
'50%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'right center'
|
||||
}
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-10px)' }
|
||||
},
|
||||
'pulse-glow': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.8 }
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
104
web/admin-spa/vite.config.js
Normal file
104
web/admin-spa/vite.config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiTarget = env.VITE_API_TARGET || 'http://localhost:3000'
|
||||
const httpProxy = env.VITE_HTTP_PROXY || env.HTTP_PROXY || env.http_proxy
|
||||
// 使用环境变量配置基础路径,如果未设置则使用默认值
|
||||
const basePath = env.VITE_APP_BASE_URL || (mode === 'development' ? '/admin/' : '/admin-next/')
|
||||
|
||||
// 创建代理配置
|
||||
const proxyConfig = {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
|
||||
// 如果设置了代理,动态导入并配置 agent(仅在开发模式下)
|
||||
if (httpProxy && mode === 'development') {
|
||||
console.log(`Using HTTP proxy: ${httpProxy}`)
|
||||
// Vite 的 proxy 使用 http-proxy,它支持通过环境变量自动使用代理
|
||||
// 设置环境变量让 http-proxy 使用代理
|
||||
process.env.HTTP_PROXY = httpProxy
|
||||
process.env.HTTPS_PROXY = httpProxy
|
||||
}
|
||||
|
||||
console.log(`${mode === 'development' ? 'Starting dev server' : 'Building'} with base path: ${basePath}`)
|
||||
|
||||
return {
|
||||
base: basePath,
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia']
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()]
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
host: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
// 统一的 API 代理规则 - 开发环境所有 API 请求都加 /webapi 前缀
|
||||
'/webapi': {
|
||||
...proxyConfig,
|
||||
rewrite: (path) => path.replace(/^\/webapi/, ''), // 转发时去掉 /webapi 前缀
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('Proxying:', req.method, req.url, '->', options.target + req.url.replace(/^\/webapi/, ''))
|
||||
})
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.log('Proxy error:', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
// API Stats 专用代理规则
|
||||
'/apiStats': {
|
||||
...proxyConfig,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('API Stats Proxying:', req.method, req.url, '->', options.target + req.url)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
// 将 vue 相关的库打包到一起
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
if (id.includes('chart.js')) {
|
||||
return 'chart'
|
||||
}
|
||||
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
return 'vendor'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
2136
web/admin-spa/yarn.lock
Normal file
2136
web/admin-spa/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
4220
web/admin/app.js
4220
web/admin/app.js
File diff suppressed because it is too large
Load Diff
3928
web/admin/index.html
3928
web/admin/index.html
File diff suppressed because it is too large
Load Diff
@@ -1,689 +0,0 @@
|
||||
// 初始化 dayjs 插件
|
||||
dayjs.extend(dayjs_plugin_relativeTime);
|
||||
dayjs.extend(dayjs_plugin_timezone);
|
||||
dayjs.extend(dayjs_plugin_utc);
|
||||
|
||||
const { createApp } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
data() {
|
||||
return {
|
||||
// 用户输入
|
||||
apiKey: '',
|
||||
apiId: null, // 存储 API Key 对应的 ID
|
||||
|
||||
// 状态控制
|
||||
loading: false,
|
||||
modelStatsLoading: false,
|
||||
error: '',
|
||||
showAdminButton: true, // 控制管理后端按钮显示
|
||||
|
||||
// 时间范围控制
|
||||
statsPeriod: 'daily', // 默认今日
|
||||
|
||||
// 数据
|
||||
statsData: null,
|
||||
modelStats: [],
|
||||
|
||||
// 分时间段的统计数据
|
||||
dailyStats: null,
|
||||
monthlyStats: null,
|
||||
|
||||
// OEM设置
|
||||
oemSettings: {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 🔍 查询统计数据
|
||||
async queryStats() {
|
||||
if (!this.apiKey.trim()) {
|
||||
this.error = '请输入 API Key';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.statsData = null;
|
||||
this.modelStats = [];
|
||||
this.apiId = null;
|
||||
|
||||
try {
|
||||
// 首先获取 API Key 对应的 ID
|
||||
const idResponse = await fetch('/apiStats/api/get-key-id', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: this.apiKey
|
||||
})
|
||||
});
|
||||
|
||||
const idResult = await idResponse.json();
|
||||
|
||||
if (!idResponse.ok) {
|
||||
throw new Error(idResult.message || '获取 API Key ID 失败');
|
||||
}
|
||||
|
||||
if (idResult.success) {
|
||||
this.apiId = idResult.data.id;
|
||||
|
||||
// 使用 apiId 查询统计数据
|
||||
const response = await fetch('/apiStats/api/user-stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiId: this.apiId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '查询失败');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.statsData = result.data;
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await this.loadAllPeriodStats();
|
||||
|
||||
// 清除错误信息
|
||||
this.error = '';
|
||||
|
||||
// 更新 URL
|
||||
this.updateURL();
|
||||
} else {
|
||||
throw new Error(result.message || '查询失败');
|
||||
}
|
||||
} else {
|
||||
throw new Error(idResult.message || '获取 API Key ID 失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Query stats error:', error);
|
||||
this.error = error.message || '查询统计数据失败,请检查您的 API Key 是否正确';
|
||||
this.statsData = null;
|
||||
this.modelStats = [];
|
||||
this.apiId = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 加载所有时间段的统计数据
|
||||
async loadAllPeriodStats() {
|
||||
if (!this.apiId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行加载今日和本月的数据
|
||||
await Promise.all([
|
||||
this.loadPeriodStats('daily'),
|
||||
this.loadPeriodStats('monthly')
|
||||
]);
|
||||
|
||||
// 加载当前选择时间段的模型统计
|
||||
await this.loadModelStats(this.statsPeriod);
|
||||
},
|
||||
|
||||
// 📊 加载指定时间段的统计数据
|
||||
async loadPeriodStats(period) {
|
||||
try {
|
||||
const response = await fetch('/apiStats/api/user-model-stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiId: this.apiId,
|
||||
period: period
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 计算汇总数据
|
||||
const modelData = result.data || [];
|
||||
const summary = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
};
|
||||
|
||||
modelData.forEach(model => {
|
||||
summary.requests += model.requests || 0;
|
||||
summary.inputTokens += model.inputTokens || 0;
|
||||
summary.outputTokens += model.outputTokens || 0;
|
||||
summary.cacheCreateTokens += model.cacheCreateTokens || 0;
|
||||
summary.cacheReadTokens += model.cacheReadTokens || 0;
|
||||
summary.allTokens += model.allTokens || 0;
|
||||
summary.cost += model.costs?.total || 0;
|
||||
});
|
||||
|
||||
summary.formattedCost = this.formatCost(summary.cost);
|
||||
|
||||
// 存储到对应的时间段数据
|
||||
if (period === 'daily') {
|
||||
this.dailyStats = summary;
|
||||
} else {
|
||||
this.monthlyStats = summary;
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load ${period} stats:`, result.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Load ${period} stats error:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 加载模型统计数据
|
||||
async loadModelStats(period = 'daily') {
|
||||
if (!this.apiId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modelStatsLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/apiStats/api/user-model-stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiId: this.apiId,
|
||||
period: period
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '加载模型统计失败');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.modelStats = result.data || [];
|
||||
} else {
|
||||
throw new Error(result.message || '加载模型统计失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load model stats error:', error);
|
||||
this.modelStats = [];
|
||||
// 不显示错误,因为模型统计是可选的
|
||||
} finally {
|
||||
this.modelStatsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 🔄 切换时间范围
|
||||
async switchPeriod(period) {
|
||||
if (this.statsPeriod === period || this.modelStatsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statsPeriod = period;
|
||||
|
||||
// 如果对应时间段的数据还没有加载,则加载它
|
||||
if ((period === 'daily' && !this.dailyStats) ||
|
||||
(period === 'monthly' && !this.monthlyStats)) {
|
||||
await this.loadPeriodStats(period);
|
||||
}
|
||||
|
||||
// 加载对应的模型统计
|
||||
await this.loadModelStats(period);
|
||||
},
|
||||
|
||||
// 📅 格式化日期
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '无';
|
||||
|
||||
try {
|
||||
// 使用 dayjs 格式化日期
|
||||
const date = dayjs(dateString);
|
||||
return date.format('YYYY年MM月DD日 HH:mm');
|
||||
} catch (error) {
|
||||
return '格式错误';
|
||||
}
|
||||
},
|
||||
|
||||
// 📅 格式化过期日期
|
||||
formatExpireDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// 🔍 检查 API Key 是否已过期
|
||||
isApiKeyExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
},
|
||||
|
||||
// ⏰ 检查 API Key 是否即将过期(7天内)
|
||||
isApiKeyExpiringSoon(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24);
|
||||
return daysUntilExpire > 0 && daysUntilExpire <= 7;
|
||||
},
|
||||
|
||||
// 🔢 格式化数字
|
||||
formatNumber(num) {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0;
|
||||
}
|
||||
|
||||
if (num === 0) return '0';
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
} else {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
},
|
||||
|
||||
// 💰 格式化费用
|
||||
formatCost(cost) {
|
||||
if (typeof cost !== 'number' || cost === 0) {
|
||||
return '$0.000000';
|
||||
}
|
||||
|
||||
// 根据数值大小选择精度
|
||||
if (cost >= 1) {
|
||||
return '$' + cost.toFixed(2);
|
||||
} else if (cost >= 0.01) {
|
||||
return '$' + cost.toFixed(4);
|
||||
} else {
|
||||
return '$' + cost.toFixed(6);
|
||||
}
|
||||
},
|
||||
|
||||
// 🔐 格式化权限
|
||||
formatPermissions(permissions) {
|
||||
const permissionMap = {
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
'all': '全部模型'
|
||||
};
|
||||
|
||||
return permissionMap[permissions] || permissions || '未知';
|
||||
},
|
||||
|
||||
// 💾 处理错误
|
||||
handleError(error, defaultMessage = '操作失败') {
|
||||
console.error('Error:', error);
|
||||
|
||||
let errorMessage = defaultMessage;
|
||||
|
||||
if (error.response) {
|
||||
// HTTP 错误响应
|
||||
if (error.response.data && error.response.data.message) {
|
||||
errorMessage = error.response.data.message;
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage = 'API Key 无效或已过期';
|
||||
} else if (error.response.status === 403) {
|
||||
errorMessage = '没有权限访问该数据';
|
||||
} else if (error.response.status === 429) {
|
||||
errorMessage = '请求过于频繁,请稍后再试';
|
||||
} else if (error.response.status >= 500) {
|
||||
errorMessage = '服务器内部错误,请稍后再试';
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
this.error = errorMessage;
|
||||
},
|
||||
|
||||
// 📋 复制到剪贴板
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.showToast('已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
this.showToast('复制失败', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 🍞 显示 Toast 通知
|
||||
showToast(message, type = 'info') {
|
||||
// 简单的 toast 实现
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg text-white transform transition-all duration-300 ${
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' :
|
||||
'bg-blue-500'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 显示动画
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
toast.style.opacity = '1';
|
||||
}, 100);
|
||||
|
||||
// 自动隐藏
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// 🧹 清除数据
|
||||
clearData() {
|
||||
this.statsData = null;
|
||||
this.modelStats = [];
|
||||
this.dailyStats = null;
|
||||
this.monthlyStats = null;
|
||||
this.error = '';
|
||||
this.statsPeriod = 'daily'; // 重置为默认值
|
||||
this.apiId = null;
|
||||
},
|
||||
|
||||
// 加载OEM设置
|
||||
async loadOemSettings() {
|
||||
try {
|
||||
const response = await fetch('/admin/oem-settings', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result && result.success && result.data) {
|
||||
this.oemSettings = { ...this.oemSettings, ...result.data };
|
||||
|
||||
// 应用设置到页面
|
||||
this.applyOemSettings();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading OEM settings:', error);
|
||||
// 静默失败,使用默认值
|
||||
}
|
||||
},
|
||||
|
||||
// 应用OEM设置
|
||||
applyOemSettings() {
|
||||
// 更新网站标题
|
||||
document.title = `API Key 统计 - ${this.oemSettings.siteName}`;
|
||||
|
||||
// 应用网站图标
|
||||
const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon;
|
||||
if (iconData && iconData.trim()) {
|
||||
// 移除现有的favicon
|
||||
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
|
||||
existingFavicons.forEach(link => link.remove());
|
||||
|
||||
// 添加新的favicon
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
|
||||
// 根据数据类型设置适当的type
|
||||
if (iconData.startsWith('data:')) {
|
||||
// Base64数据
|
||||
link.href = iconData;
|
||||
} else {
|
||||
// URL
|
||||
link.type = 'image/x-icon';
|
||||
link.href = iconData;
|
||||
}
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
},
|
||||
|
||||
// 🔄 刷新数据
|
||||
async refreshData() {
|
||||
if (this.statsData && this.apiKey) {
|
||||
await this.queryStats();
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 刷新当前时间段数据
|
||||
async refreshCurrentPeriod() {
|
||||
if (this.apiId) {
|
||||
await this.loadPeriodStats(this.statsPeriod);
|
||||
await this.loadModelStats(this.statsPeriod);
|
||||
}
|
||||
},
|
||||
|
||||
// 🔄 更新 URL
|
||||
updateURL() {
|
||||
if (this.apiId) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('apiId', this.apiId);
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 使用 apiId 直接加载数据
|
||||
async loadStatsWithApiId() {
|
||||
if (!this.apiId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.statsData = null;
|
||||
this.modelStats = [];
|
||||
|
||||
try {
|
||||
// 使用 apiId 查询统计数据
|
||||
const response = await fetch('/apiStats/api/user-stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiId: this.apiId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '查询失败');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.statsData = result.data;
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await this.loadAllPeriodStats();
|
||||
|
||||
// 清除错误信息
|
||||
this.error = '';
|
||||
} else {
|
||||
throw new Error(result.message || '查询失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load stats with apiId error:', error);
|
||||
this.error = error.message || '查询统计数据失败';
|
||||
this.statsData = null;
|
||||
this.modelStats = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 📊 当前时间段的数据
|
||||
currentPeriodData() {
|
||||
if (this.statsPeriod === 'daily') {
|
||||
return this.dailyStats || {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
};
|
||||
} else {
|
||||
return this.monthlyStats || {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 📊 使用率计算(基于当前时间段)
|
||||
usagePercentages() {
|
||||
if (!this.statsData || !this.currentPeriodData) {
|
||||
return {
|
||||
tokenUsage: 0,
|
||||
costUsage: 0,
|
||||
requestUsage: 0
|
||||
};
|
||||
}
|
||||
|
||||
const current = this.currentPeriodData;
|
||||
const limits = this.statsData.limits;
|
||||
|
||||
return {
|
||||
tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
|
||||
costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
|
||||
requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0
|
||||
};
|
||||
},
|
||||
|
||||
// 📈 统计摘要(基于当前时间段)
|
||||
statsSummary() {
|
||||
if (!this.statsData || !this.currentPeriodData) return null;
|
||||
|
||||
const current = this.currentPeriodData;
|
||||
|
||||
return {
|
||||
totalRequests: current.requests || 0,
|
||||
totalTokens: current.allTokens || 0,
|
||||
totalCost: current.cost || 0,
|
||||
formattedCost: current.formattedCost || '$0.000000',
|
||||
inputTokens: current.inputTokens || 0,
|
||||
outputTokens: current.outputTokens || 0,
|
||||
cacheCreateTokens: current.cacheCreateTokens || 0,
|
||||
cacheReadTokens: current.cacheReadTokens || 0
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// 监听 API Key 变化
|
||||
apiKey(newValue) {
|
||||
if (!newValue) {
|
||||
this.clearData();
|
||||
}
|
||||
// 清除之前的错误
|
||||
if (this.error) {
|
||||
this.error = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// 页面加载完成后的初始化
|
||||
console.log('User Stats Page loaded');
|
||||
|
||||
// 加载OEM设置
|
||||
this.loadOemSettings();
|
||||
|
||||
// 检查 URL 参数是否有预填的 API Key(用于开发测试)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const presetApiId = urlParams.get('apiId');
|
||||
const presetApiKey = urlParams.get('apiKey');
|
||||
|
||||
if (presetApiId && presetApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||
this.apiId = presetApiId;
|
||||
this.showAdminButton = false; // 隐藏管理后端按钮
|
||||
this.loadStatsWithApiId();
|
||||
} else if (presetApiKey && presetApiKey.length > 10) {
|
||||
// 向后兼容,支持 apiKey 参数
|
||||
this.apiKey = presetApiKey;
|
||||
}
|
||||
|
||||
// 添加键盘快捷键
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// Ctrl/Cmd + Enter 查询
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
if (!this.loading && this.apiKey.trim()) {
|
||||
this.queryStats();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// ESC 清除数据
|
||||
if (event.key === 'Escape') {
|
||||
this.clearData();
|
||||
this.apiKey = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 定期清理无效的 toast 元素
|
||||
setInterval(() => {
|
||||
const toasts = document.querySelectorAll('[class*="fixed top-4 right-4"]');
|
||||
toasts.forEach(toast => {
|
||||
if (toast.style.opacity === '0') {
|
||||
try {
|
||||
document.body.removeChild(toast);
|
||||
} catch (e) {
|
||||
// 忽略已经被移除的元素
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
// 组件销毁前清理
|
||||
beforeUnmount() {
|
||||
// 清理事件监听器
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app');
|
||||
@@ -1,497 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Key 统计</title>
|
||||
|
||||
<!-- 🎨 样式 -->
|
||||
<link rel="stylesheet" href="/apiStats/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<style>
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 调整间距使其与管理页面一致 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 与管理页面一致的按钮样式 */
|
||||
.glass-button {
|
||||
background: var(--glass-color, rgba(255, 255, 255, 0.1));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
|
||||
/* 调整卡片样式 */
|
||||
.card {
|
||||
background: var(--surface-color, rgba(255, 255, 255, 0.95));
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 🔧 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
|
||||
<!-- 📊 Charts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<!-- 🧮 工具库 -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/dayjs.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/relativeTime.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/timezone.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/utc.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="app" v-cloak class="min-h-screen p-6">
|
||||
<!-- 🎯 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
|
||||
<img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
alt="Logo"
|
||||
class="w-8 h-8 object-contain"
|
||||
@error="(e) => e.target.style.display = 'none'">
|
||||
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center min-h-[48px]">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-white header-title leading-tight">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm leading-tight mt-0.5">API Key 使用统计</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/web" class="glass-button rounded-xl px-4 py-2 text-gray-700 hover:bg-white/20 transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-cog text-sm"></i>
|
||||
<span class="text-sm font-medium">管理后台</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🔑 API Key 输入区域 -->
|
||||
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<!-- 📊 标题区域 -->
|
||||
<div class="wide-card-title text-center mb-6">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<i class="fas fa-chart-line mr-3"></i>
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 🔍 输入区域 -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
|
||||
<!-- API Key 输入 -->
|
||||
<div class="lg:col-span-3">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
输入您的 API Key
|
||||
</label>
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
class="wide-card-input w-full"
|
||||
@keyup.enter="queryStats"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<div class="lg:col-span-1">
|
||||
<button
|
||||
@click="queryStats"
|
||||
:disabled="loading || !apiKey.trim()"
|
||||
class="btn btn-primary w-full px-6 py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner"></i>
|
||||
<i v-else class="fas fa-search"></i>
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ❌ 错误提示 -->
|
||||
<div v-if="error" class="mb-8">
|
||||
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📊 统计数据展示区域 -->
|
||||
<div v-if="statsData" class="fade-in">
|
||||
<!-- 主要内容卡片 -->
|
||||
<div class="glass-strong rounded-3xl p-6 shadow-xl">
|
||||
<!-- 📅 时间范围选择器 -->
|
||||
<div class="mb-6 pb-6 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-clock text-blue-500 text-lg"></i>
|
||||
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="switchPeriod('daily')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
@click="switchPeriod('monthly')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 📈 基本信息卡片 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- API Key 基本信息 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
|
||||
API Key 信息
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">名称</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">状态</span>
|
||||
<span :class="statsData.isActive ? 'text-green-600' : 'text-red-600'" class="font-medium">
|
||||
<i :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">权限</span>
|
||||
<span class="font-medium text-gray-900">{{ formatPermissions(statsData.permissions) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">创建时间</span>
|
||||
<span class="font-medium text-gray-900">{{ formatDate(statsData.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">过期时间</span>
|
||||
<div v-if="statsData.expiresAt">
|
||||
<div v-if="isApiKeyExpired(statsData.expiresAt)" class="text-red-600 font-medium">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
已过期
|
||||
</div>
|
||||
<div v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" class="text-orange-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-900 font-medium">
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 font-medium">
|
||||
<i class="fas fa-infinity mr-1"></i>
|
||||
永不过期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用统计概览 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-chart-bar mr-3 text-green-500"></i>
|
||||
使用统计概览 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📋 详细使用数据 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Token 分类统计 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-coins mr-3 text-yellow-500"></i>
|
||||
Token 使用分布 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-right mr-2 text-green-500"></i>
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-left mr-2 text-blue-500"></i>
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-save mr-2 text-purple-500"></i>
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-download mr-2 text-orange-500"></i>
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex justify-between items-center font-bold text-gray-900">
|
||||
<span>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
|
||||
<span class="text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限制设置 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
限制配置
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">Token 限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">并发限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">速率限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
|
||||
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
|
||||
: '无限制' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">每日费用限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">模型限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有模型
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">客户端限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有客户端
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📋 详细限制信息 -->
|
||||
<div v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
|
||||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
|
||||
class="card p-6 mb-8">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-list-alt mr-3 text-amber-500"></i>
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 模型限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-amber-800 mb-3 flex items-center">
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="model in statsData.restrictions.restrictedModels"
|
||||
:key="model"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-amber-200">
|
||||
<i class="fas fa-ban mr-2 text-red-500"></i>
|
||||
<span class="text-gray-800">{{ model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-amber-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-blue-800 mb-3 flex items-center">
|
||||
<i class="fas fa-desktop mr-2"></i>
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in statsData.restrictions.allowedClients"
|
||||
:key="client"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-blue-200">
|
||||
<i class="fas fa-check mr-2 text-green-500"></i>
|
||||
<span class="text-gray-800">{{ client }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📊 模型使用统计 -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold flex items-center text-gray-900">
|
||||
<i class="fas fa-robot mr-3 text-indigo-500"></i>
|
||||
模型使用统计 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计加载状态 -->
|
||||
<div v-if="modelStatsLoading" class="text-center py-8">
|
||||
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600"></i>
|
||||
<p class="text-gray-600">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
<div v-else-if="modelStats.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(model, index) in modelStats"
|
||||
:key="index"
|
||||
class="model-usage-item"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 class="font-bold text-lg text-gray-900">{{ model.model }}</h4>
|
||||
<p class="text-gray-600 text-sm">{{ model.requests }} 次请求</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-green-600">{{ model.formatted?.total || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输入 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.inputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输出 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.outputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存创建</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheCreateTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存读取</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheReadTokens) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无模型数据 -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-chart-pie text-3xl mb-3"></i>
|
||||
<p>暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📱 JavaScript -->
|
||||
<script src="/apiStats/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,870 +0,0 @@
|
||||
/* 🎨 用户统计页面自定义样式 - 与管理页面保持一致 */
|
||||
|
||||
/* CSS 变量 - 与管理页面保持一致 */
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 📱 响应式布局优化 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card .text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.input-field, .btn-primary {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card .text-2xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card .text-sm {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flex.gap-3 {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🌈 渐变背景 - 与管理页面一致 */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
/* 移除原有的渐变,使用body的背景 */
|
||||
}
|
||||
|
||||
/* ✨ 卡片样式 - 与管理页面一致 */
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 🎯 统计卡片样式 - 与管理页面一致 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 🔍 输入框样式 - 与管理页面一致 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* 兼容旧的 input-field 类名 */
|
||||
.input-field {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* ====== 系统标题样式 ====== */
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* ====== 玻璃按钮样式 ====== */
|
||||
.glass-button {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
-webkit-backdrop-filter: blur(10px) !important;
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
color: white !important;
|
||||
text-decoration: none !important;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05) !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glass-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.glass-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important;
|
||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1) !important;
|
||||
color: white !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* 🎨 按钮样式 - 与管理页面一致 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 🎯 修复时间范围按钮样式 */
|
||||
.btn-primary {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* 🎯 时间范围按钮 - 与管理页面 tab-btn 样式一致 */
|
||||
.period-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.period-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.period-btn:not(.active) {
|
||||
color: #374151;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.period-btn:not(.active):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 📊 模型使用项样式 - 与管理页面保持一致 */
|
||||
.model-usage-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-usage-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.model-usage-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 🔄 加载动画增强 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 🌟 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎯 焦点样式增强 */
|
||||
.input-field:focus-visible,
|
||||
.btn-primary:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 📱 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 🚨 错误状态样式 */
|
||||
.error-border {
|
||||
border-color: #ef4444 !important;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* 🎉 成功状态样式 */
|
||||
.success-border {
|
||||
border-color: #10b981 !important;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* 🌙 深色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🔍 高对比度模式支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
.card {
|
||||
border-width: 2px;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border-width: 2px;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* 📊 数据可视化增强 */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 🎨 图标动画 */
|
||||
.fas {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .fas {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 💫 悬浮效果 */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 🎯 选中状态 */
|
||||
.selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-color: rgba(255, 255, 255, 0.4) !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 🌈 彩虹边框效果 */
|
||||
.rainbow-border {
|
||||
position: relative;
|
||||
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #feca57);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
padding: 2px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rainbow-border > * {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* 🎯 单层宽卡片样式优化 */
|
||||
.api-input-wide-card {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.api-input-wide-card:hover {
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(0, 0, 0, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 🎯 宽卡片内标题样式 */
|
||||
.wide-card-title h2 {
|
||||
color: #1f2937 !important;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
color: #4b5563 !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.wide-card-title .fas.fa-chart-line {
|
||||
color: #3b82f6 !important;
|
||||
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 🎯 网格布局优化 */
|
||||
.api-input-grid {
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.api-input-grid {
|
||||
grid-template-columns: 3fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎯 输入框在宽卡片中的样式调整 */
|
||||
.wide-card-input {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wide-card-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.wide-card-input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96, 165, 250, 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* 🎯 安全提示样式优化 */
|
||||
.security-notice {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.security-notice:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.security-notice .fas.fa-shield-alt {
|
||||
color: #10b981 !important;
|
||||
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* 🎯 时间范围选择器在卡片内的样式优化 */
|
||||
.time-range-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.time-range-section:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* 📱 响应式优化 - 宽卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1.25rem !important;
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.wide-card-title {
|
||||
margin-bottom: 1.25rem !important;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
.api-input-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.wide-card-input {
|
||||
padding: 12px 14px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
padding: 10px 14px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1rem !important;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 📱 响应式优化 - 时间范围选择器 */
|
||||
@media (max-width: 768px) {
|
||||
.time-range-section .flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.time-range-section .flex .flex {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 📱 触摸设备优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.card:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: none;
|
||||
background-position: 0% 0;
|
||||
}
|
||||
|
||||
.model-usage-item:hover {
|
||||
transform: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.time-range-section:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.query-title-section:hover {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.api-input-wide-card:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.security-notice:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎯 打印样式 */
|
||||
@media print {
|
||||
.gradient-bg {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ccc !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid #ccc !important;
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user