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:
shaw
2025-07-29 12:40:51 +08:00
parent c98de2aca5
commit 414856f152
70 changed files with 18748 additions and 10314 deletions

View 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

View 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
View 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
View 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 路由回退规则

View File

@@ -0,0 +1 @@
# This file keeps the empty directory in git

26
web/admin-spa/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
web/admin-spa/src/App.vue Normal file
View 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>

View 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);
}

View File

@@ -0,0 +1,572 @@
/* 从原始 style.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);
}
/* 通用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);
}
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;
}
.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);
}
/* 按钮样式 */
.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;
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: 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);
}
.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);
}
.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); }
}
.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);
}
.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);
}
[v-cloak] {
display: none;
}
/* 自定义滚动条样式 */
.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%);
}
/* 弹窗滚动内容样式 */
.modal-scroll-content {
max-height: calc(90vh - 160px);
overflow-y: auto;
padding-right: 8px;
}
@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);
}
}
/* 版本更新提醒动画 */
@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);
}
/* Tab 内容区域样式 */
.tab-content {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View 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);
}
}

View 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);
}

View 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"> 注意不要复制项目IDProject 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 TokenToken 过期后需要手动更新</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>

View 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>如果页面一直无法跳转可以打开浏览器开发者工具F12F5刷新一下授权页再点击页面的登录按钮网络标签中找到以 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>

View 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>

View 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> 时间窗口=1Token限制=10000</p>
<p class="ml-4"> 每分钟最多消耗10,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=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>

View 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> 时间窗口=10Token限制=50000</p>
<p class="ml-4"> 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=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>

View 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>

View 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>

View 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">
&nbsp;
</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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
}
}

View 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()

View 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()

View 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
View 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')

View 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

View 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
}
})

View 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
}
})

View 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
}
})

View 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
}
})

View 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
}
}
}
})

View 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
}
})

View 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
}
})

View 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]
}

View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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: [],
}

View 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

File diff suppressed because it is too large Load Diff