mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 全新的Vue3管理后台(admin-spa)和路由重构
🎨 新增功能: - 使用Vue3 + Vite构建的全新管理后台界面 - 支持Tab切换的API统计页面(统计查询/使用教程) - 优雅的胶囊式Tab切换设计 - 同步了PR #106的会话窗口管理功能 - 完整的响应式设计和骨架屏加载状态 🔧 路由调整: - 新版管理后台部署在 /admin-next/ 路径 - 将根路径 / 重定向到 /admin-next/api-stats - 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由 - 将 /apiStats 页面路由重定向到新版,保留API端点 🗑️ 清理工作: - 删除旧版 web/admin/ 静态文件 - 删除旧版 web/apiStats/ 静态文件 - 清理相关的文件服务代码 🐛 修复问题: - 修复重定向循环问题 - 修复环境变量配置 - 修复路由404错误 - 优化构建配置 🚀 生成方式:使用 Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
646
web/admin-spa/src/components/accounts/AccountForm.vue
Normal file
646
web/admin-spa/src/components/accounts/AccountForm.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-user-circle text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ isEdit ? '编辑账户' : '添加账户' }}</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤指示器 -->
|
||||
<div v-if="!isEdit && form.addType === 'oauth'" class="flex items-center justify-center mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
|
||||
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
|
||||
1
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">基本信息</span>
|
||||
</div>
|
||||
<div class="w-8 h-0.5 bg-gray-300"></div>
|
||||
<div class="flex items-center">
|
||||
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
|
||||
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
|
||||
2
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">授权认证</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤1: 基本信息和代理设置 -->
|
||||
<div v-if="oauthStep === 1 && !isEdit">
|
||||
<div class="space-y-6">
|
||||
<div v-if="!isEdit">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">平台</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.platform"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.platform"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEdit">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.addType"
|
||||
value="oauth"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">OAuth 授权 (推荐)</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.addType"
|
||||
value="manual"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">手动输入 Access Token</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="账户用途说明..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="shared"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">共享账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="dedicated"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
<input
|
||||
v-model="form.projectId"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="例如:123456789012(纯数字)"
|
||||
>
|
||||
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
|
||||
<div class="text-xs text-yellow-700">
|
||||
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
|
||||
<p>某些 Google 账号(特别是绑定了 Google Cloud 的账号)会被识别为 Workspace 账号,需要提供额外的项目编号。</p>
|
||||
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
|
||||
<p class="font-medium mb-1">如何获取项目编号:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
|
||||
<li>复制<span class="font-semibold text-red-600">项目编号(Project Number)</span>,通常是12位纯数字</li>
|
||||
<li class="text-red-600">⚠️ 注意:不要复制项目ID(Project ID),要复制项目编号!</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud),请留空此字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 Token 字段 -->
|
||||
<div v-if="form.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<i class="fas fa-info text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
|
||||
<p v-if="form.platform === 'claude'" class="text-sm text-blue-800 mb-2">
|
||||
请输入有效的 Claude Access Token。如果您有 Refresh Token,建议也一并填写以支持自动刷新。
|
||||
</p>
|
||||
<p v-else-if="form.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
|
||||
请输入有效的 Gemini Access Token。如果您有 Refresh Token,建议也一并填写以支持自动刷新。
|
||||
</p>
|
||||
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
|
||||
<p class="text-sm text-blue-900 font-medium mb-1">
|
||||
<i class="fas fa-folder-open mr-1"></i>
|
||||
获取 Access Token 的方法:
|
||||
</p>
|
||||
<p v-if="form.platform === 'claude'" class="text-xs text-blue-800">
|
||||
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证,
|
||||
请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||
</p>
|
||||
<p v-else-if="form.platform === 'gemini'" class="text-xs text-blue-800">
|
||||
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code> 文件中的凭证。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600">💡 如果未填写 Refresh Token,Token 过期后需要手动更新。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Access Token *</label>
|
||||
<textarea
|
||||
v-model="form.accessToken"
|
||||
rows="4"
|
||||
required
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="请输入 Access Token..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Refresh Token (可选)</label>
|
||||
<textarea
|
||||
v-model="form.refreshToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="请输入 Refresh Token..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理设置 -->
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
v-if="form.addType === 'oauth'"
|
||||
type="button"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceed"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="createAccount"
|
||||
:disabled="loading || !canCreate"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: OAuth授权 -->
|
||||
<OAuthFlow
|
||||
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
||||
:platform="form.platform"
|
||||
:proxy="form.proxy"
|
||||
@success="handleOAuthSuccess"
|
||||
@back="oauthStep = 1"
|
||||
/>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="isEdit" class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="账户用途说明..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="shared"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">共享账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.accountType"
|
||||
value="dedicated"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
<input
|
||||
v-model="form.projectId"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="例如:123456789012(纯数字)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Google Cloud/Workspace 账号可能需要提供项目编号
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Token 更新 -->
|
||||
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<i class="fas fa-key text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-semibold text-amber-900 mb-2">更新 Token</h5>
|
||||
<p class="text-sm text-amber-800 mb-2">可以更新 Access Token 和 Refresh Token。为了安全起见,不会显示当前的 Token 值。</p>
|
||||
<p class="text-xs text-amber-600">💡 留空表示不更新该字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Access Token</label>
|
||||
<textarea
|
||||
v-model="form.accessToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="留空表示不更新..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Refresh Token</label>
|
||||
<textarea
|
||||
v-model="form.refreshToken"
|
||||
rows="4"
|
||||
class="form-input w-full resize-none font-mono text-xs"
|
||||
placeholder="留空表示不更新..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理设置 -->
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="updateAccount"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认弹窗 -->
|
||||
<ConfirmModal
|
||||
:show="showConfirmModal"
|
||||
:title="confirmOptions.title"
|
||||
:message="confirmOptions.message"
|
||||
:confirm-text="confirmOptions.confirmText"
|
||||
:cancel-text="confirmOptions.cancelText"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const show = ref(true)
|
||||
|
||||
// OAuth步骤
|
||||
const oauthStep = ref(1)
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
addType: 'oauth',
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
projectId: props.account?.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
proxy: props.account?.proxy || {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 计算是否可以进入下一步
|
||||
const canProceed = computed(() => {
|
||||
return form.value.name && form.value.platform
|
||||
})
|
||||
|
||||
// 计算是否可以创建
|
||||
const canCreate = computed(() => {
|
||||
if (form.value.addType === 'manual') {
|
||||
return form.value.name && form.value.accessToken
|
||||
}
|
||||
return form.value.name
|
||||
})
|
||||
|
||||
// 下一步
|
||||
const nextStep = async () => {
|
||||
if (!canProceed.value) {
|
||||
if (!form.value.name || form.value.name.trim() === '') {
|
||||
showToast('请填写账户名称', 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续',
|
||||
'返回填写'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oauthStep.value = 2
|
||||
}
|
||||
|
||||
// 处理OAuth成功
|
||||
const handleOAuthSuccess = async (tokenInfo) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
accessToken: tokenInfo.access_token,
|
||||
refreshToken: tokenInfo.refresh_token,
|
||||
scopes: tokenInfo.scopes || [],
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
if (form.value.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
} else {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
}
|
||||
|
||||
showToast('账户创建成功', 'success')
|
||||
emit('success', result)
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建账户(手动模式)
|
||||
const createAccount = async () => {
|
||||
if (!canCreate.value) {
|
||||
if (!form.value.name || form.value.name.trim() === '') {
|
||||
showToast('请填写账户名称', 'error')
|
||||
} else if (!form.value.accessToken || form.value.accessToken.trim() === '') {
|
||||
showToast('请填写 Access Token', 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
accessToken: form.value.accessToken,
|
||||
refreshToken: form.value.refreshToken || undefined,
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
if (form.value.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
} else {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
}
|
||||
|
||||
showToast('账户创建成功', 'success')
|
||||
emit('success', result)
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
const updateAccount = async () => {
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续保存',
|
||||
'返回填写'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
accountType: form.value.accountType,
|
||||
proxy: form.value.proxy.enabled ? form.value.proxy : null
|
||||
}
|
||||
|
||||
// 只有非空时才更新token
|
||||
if (form.value.accessToken) {
|
||||
data.accessToken = form.value.accessToken
|
||||
}
|
||||
if (form.value.refreshToken) {
|
||||
data.refreshToken = form.value.refreshToken
|
||||
}
|
||||
|
||||
if (props.account.platform === 'gemini' && form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
|
||||
if (props.account.platform === 'claude') {
|
||||
await accountsStore.updateClaudeAccount(props.account.id, data)
|
||||
} else {
|
||||
await accountsStore.updateGeminiAccount(props.account.id, data)
|
||||
}
|
||||
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
showToast(error.message || '账户更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听账户变化,更新表单
|
||||
watch(() => props.account, (newAccount) => {
|
||||
if (newAccount) {
|
||||
form.value = {
|
||||
platform: newAccount.platform,
|
||||
addType: 'oauth',
|
||||
name: newAccount.name,
|
||||
description: newAccount.description || '',
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
projectId: newAccount.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
proxy: newAccount.proxy || {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
377
web/admin-spa/src/components/accounts/OAuthFlow.vue
Normal file
377
web/admin-spa/src/components/accounts/OAuthFlow.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Claude OAuth流程 -->
|
||||
<div v-if="platform === 'claude'">
|
||||
<div class="bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-link text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-blue-900 mb-3">Claude 账户授权</h4>
|
||||
<p class="text-sm text-blue-800 mb-4">
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@click="generateAuthUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2"></i>
|
||||
<div v-else class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
@click="copyAuthUrl"
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="regenerateAuthUrl"
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 访问链接并授权 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">在浏览器中打开链接并完成授权</p>
|
||||
<p class="text-sm text-blue-700 mb-2">
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
|
||||
<p class="text-xs text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<strong>注意:</strong>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">输入 Authorization Code</p>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
授权完成后,页面会显示一个 <strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-blue-500 mr-2"></i>Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
></textarea>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini OAuth流程 -->
|
||||
<div v-else-if="platform === 'gemini'">
|
||||
<div class="bg-green-50 p-6 rounded-lg border border-green-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-robot text-white"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-green-900 mb-3">Gemini 账户授权</h4>
|
||||
<p class="text-sm text-green-800 mb-4">
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@click="generateAuthUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
>
|
||||
<i v-if="!loading" class="fas fa-link mr-2"></i>
|
||||
<div v-else class="loading-spinner mr-2"></div>
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
@click="copyAuthUrl"
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="regenerateAuthUrl"
|
||||
class="text-xs text-green-600 hover:text-green-700"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 操作说明 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">在浏览器中打开链接并完成授权</p>
|
||||
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside mb-3">
|
||||
<li>点击上方的授权链接,在新页面中完成Google账号登录</li>
|
||||
<li>点击“登录”按钮后可能会加载很慢(这是正常的)</li>
|
||||
<li>如果超过1分钟还在加载,请按 F5 刷新页面</li>
|
||||
<li>授权完成后会跳转到 http://localhost:45462 (可能显示无法访问)</li>
|
||||
</ol>
|
||||
<div class="bg-green-100 p-3 rounded border border-green-300">
|
||||
<p class="text-xs text-green-700">
|
||||
<i class="fas fa-lightbulb mr-1"></i>
|
||||
<strong>提示:</strong>如果页面一直无法跳转,可以打开浏览器开发者工具(F12),F5刷新一下授权页再点击页面的登录按钮,在“网络”标签中找到以 localhost:45462 开头的请求,复制其完整URL。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">复制oauth后的链接</p>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
复制浏览器地址栏的完整链接并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-green-500 mr-2"></i>复制oauth后的链接
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴以 http://localhost:45462 开头的完整链接..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1"></i>
|
||||
支持粘贴完整链接,系统会自动提取授权码
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1"></i>
|
||||
也可以直接粘贴授权码(code参数的值)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('back')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="exchangeCode"
|
||||
:disabled="!canExchange || exchanging"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="exchanging" class="loading-spinner mr-2"></div>
|
||||
{{ exchanging ? '验证中...' : '完成授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
|
||||
const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
proxy: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success', 'back'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const exchanging = ref(false)
|
||||
const authUrl = ref('')
|
||||
const authCode = ref('')
|
||||
const copied = ref(false)
|
||||
|
||||
// 计算是否可以交换code
|
||||
const canExchange = computed(() => {
|
||||
return authUrl.value && authCode.value.trim()
|
||||
})
|
||||
|
||||
// 生成授权URL
|
||||
const generateAuthUrl = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const proxyConfig = props.proxy?.enabled ? {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: props.proxy.port,
|
||||
username: props.proxy.username,
|
||||
password: props.proxy.password
|
||||
} : null
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
authUrl.value = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||
} else if (props.platform === 'gemini') {
|
||||
authUrl.value = await accountsStore.generateGeminiAuthUrl(proxyConfig)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '生成授权链接失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成授权URL
|
||||
const regenerateAuthUrl = () => {
|
||||
authUrl.value = ''
|
||||
authCode.value = ''
|
||||
generateAuthUrl()
|
||||
}
|
||||
|
||||
// 复制授权URL
|
||||
const copyAuthUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl.value)
|
||||
copied.value = true
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
// 降级方案
|
||||
const input = document.createElement('input')
|
||||
input.value = authUrl.value
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
showToast('链接已复制', 'success')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 交换授权码
|
||||
const exchangeCode = async () => {
|
||||
if (!canExchange.value) return
|
||||
|
||||
exchanging.value = true
|
||||
try {
|
||||
const data = {
|
||||
code: authCode.value.trim()
|
||||
}
|
||||
|
||||
if (props.proxy?.enabled) {
|
||||
data.proxy = {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: props.proxy.port,
|
||||
username: props.proxy.username,
|
||||
password: props.proxy.password
|
||||
}
|
||||
}
|
||||
|
||||
let tokenInfo
|
||||
if (props.platform === 'claude') {
|
||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||
} else if (props.platform === 'gemini') {
|
||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||
}
|
||||
|
||||
emit('success', tokenInfo)
|
||||
} catch (error) {
|
||||
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
166
web/admin-spa/src/components/accounts/ProxyConfig.vue
Normal file
166
web/admin-spa/src/components/accounts/ProxyConfig.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="proxy.enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700">启用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.enabled" class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-server text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700">
|
||||
配置代理以访问受限的网络资源。支持 SOCKS5 和 HTTP 代理。
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
请确保代理服务器稳定可用,否则会影响账户的正常使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">代理类型</label>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">主机地址</label>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
type="text"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">端口</label>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
type="number"
|
||||
placeholder="例如: 1080"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showAuth"
|
||||
id="proxyAuth"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="proxyAuth" class="ml-2 text-sm text-gray-700 cursor-pointer">
|
||||
需要身份验证
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
type="text"
|
||||
placeholder="代理用户名"
|
||||
class="form-input w-full"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="代理密码"
|
||||
class="form-input w-full pr-10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<p class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<strong>提示:</strong>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: '',
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 内部代理数据
|
||||
const proxy = ref({ ...props.modelValue })
|
||||
|
||||
// UI状态
|
||||
const showAuth = ref(!!(proxy.value.username || proxy.value.password))
|
||||
const showPassword = ref(false)
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
proxy.value = { ...newVal }
|
||||
showAuth.value = !!(newVal.username || newVal.password)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听proxy变化,更新父组件
|
||||
watch(proxy, (newVal) => {
|
||||
// 如果不需要认证,清空用户名密码
|
||||
if (!showAuth.value) {
|
||||
newVal.username = ''
|
||||
newVal.password = ''
|
||||
}
|
||||
emit('update:modelValue', { ...newVal })
|
||||
}, { deep: true })
|
||||
|
||||
// 监听认证开关
|
||||
watch(showAuth, (newVal) => {
|
||||
if (!newVal) {
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
534
web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
Normal file
534
web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-key text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">创建新的 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="为您的 API Key 取一个名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
|
||||
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
type="number"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="bg-blue-100 rounded-lg p-3 mt-3">
|
||||
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p><strong>示例1:</strong> 时间窗口=60,请求次数限制=1000</p>
|
||||
<p class="ml-4">→ 每60分钟内最多1000次请求</p>
|
||||
<p class="mt-2"><strong>示例2:</strong> 时间窗口=1,Token限制=10000</p>
|
||||
<p class="ml-4">→ 每分钟最多消耗10,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">备注 (可选)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="描述此 API Key 的用途..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">有效期限</label>
|
||||
<select
|
||||
v-model="form.expireDuration"
|
||||
@change="updateExpireAt"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="">永不过期</option>
|
||||
<option value="1d">1 天</option>
|
||||
<option value="7d">7 天</option>
|
||||
<option value="30d">30 天</option>
|
||||
<option value="90d">90 天</option>
|
||||
<option value="180d">180 天</option>
|
||||
<option value="365d">365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
</select>
|
||||
<div v-if="form.expireDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
type="datetime-local"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
@change="updateCustomExpireAt"
|
||||
>
|
||||
</div>
|
||||
<p v-if="form.expiresAt" class="text-xs text-gray-500 mt-2">
|
||||
将于 {{ formatExpireDate(form.expiresAt) }} 过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="all"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.claude.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableModelRestriction"
|
||||
id="enableModelRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="enableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
type="text"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
class="form-input flex-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型,例如:claude-opus-4-20250514</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制 -->
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableClientRestriction"
|
||||
id="enableClientRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="enableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
|
||||
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`client_${client.id}`"
|
||||
:value="client.id"
|
||||
v-model="form.allowedClients"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
|
||||
>
|
||||
<label :for="`client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
|
||||
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !form.name"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-plus mr-2"></i>
|
||||
{{ loading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
type: Object,
|
||||
default: () => ({ claude: [], gemini: [] })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
expireDuration: '',
|
||||
customExpireDate: '',
|
||||
expiresAt: null,
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
})
|
||||
|
||||
// 加载支持的客户端
|
||||
onMounted(async () => {
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
})
|
||||
|
||||
// 计算最小日期时间
|
||||
const minDateTime = computed(() => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 1)
|
||||
return now.toISOString().slice(0, 16)
|
||||
})
|
||||
|
||||
// 更新过期时间
|
||||
const updateExpireAt = () => {
|
||||
if (!form.expireDuration) {
|
||||
form.expiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.expireDuration === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const duration = form.expireDuration
|
||||
const match = duration.match(/(\d+)([dhmy])/)
|
||||
|
||||
if (match) {
|
||||
const [, value, unit] = match
|
||||
const num = parseInt(value)
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
now.setDate(now.getDate() + num)
|
||||
break
|
||||
case 'h':
|
||||
now.setHours(now.getHours() + num)
|
||||
break
|
||||
case 'm':
|
||||
now.setMonth(now.getMonth() + num)
|
||||
break
|
||||
case 'y':
|
||||
now.setFullYear(now.getFullYear() + num)
|
||||
break
|
||||
}
|
||||
|
||||
form.expiresAt = now.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新自定义过期时间
|
||||
const updateCustomExpireAt = () => {
|
||||
if (form.customExpireDate) {
|
||||
form.expiresAt = new Date(form.customExpireDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
form.restrictedModels.push(form.modelInput)
|
||||
form.modelInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除限制的模型
|
||||
const removeRestrictedModel = (index) => {
|
||||
form.restrictedModels.splice(index, 1)
|
||||
}
|
||||
|
||||
// 创建 API Key
|
||||
const createApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
|
||||
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : null,
|
||||
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : null,
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
expiresAt: form.expiresAt || undefined,
|
||||
permissions: form.permissions,
|
||||
claudeAccountId: form.claudeAccountId || undefined,
|
||||
geminiAccountId: form.geminiAccountId || undefined
|
||||
}
|
||||
|
||||
// 模型限制
|
||||
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
|
||||
data.restrictedModels = form.restrictedModels
|
||||
}
|
||||
|
||||
// 客户端限制
|
||||
if (form.enableClientRestriction && form.allowedClients.length > 0) {
|
||||
data.allowedClients = form.allowedClients
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/api-keys', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 创建成功', 'success')
|
||||
emit('success', result.apiKey)
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '创建失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
447
web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
Normal file
447
web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-edit text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">编辑 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
:value="form.name"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-tachometer-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
|
||||
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口),0 或留空表示无限制</p>
|
||||
</div>
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="bg-blue-100 rounded-lg p-3 mt-3">
|
||||
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p><strong>示例1:</strong> 时间窗口=60,请求次数限制=100</p>
|
||||
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
|
||||
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10,Token限制=50000</p>
|
||||
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=100000</p>
|
||||
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="all"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.permissions"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.claude"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">使用共享账号池</option>
|
||||
<option
|
||||
v-for="account in accounts.gemini"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableModelRestriction"
|
||||
id="editEnableModelRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="editEnableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
type="text"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
class="form-input flex-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型,例如:claude-opus-4-20250514</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制 -->
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enableClientRestriction"
|
||||
id="editEnableClientRestriction"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label for="editEnableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
|
||||
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`edit_client_${client.id}`"
|
||||
:value="client.id"
|
||||
v-model="form.allowedClients"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
|
||||
>
|
||||
<label :for="`edit_client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
|
||||
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ loading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
accounts: {
|
||||
type: Object,
|
||||
default: () => ({ claude: [], gemini: [] })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
permissions: 'all',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
})
|
||||
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
form.restrictedModels.push(form.modelInput)
|
||||
form.modelInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除限制的模型
|
||||
const removeRestrictedModel = (index) => {
|
||||
form.restrictedModels.splice(index, 1)
|
||||
}
|
||||
|
||||
// 更新 API Key
|
||||
const updateApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
|
||||
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : 0,
|
||||
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0,
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
permissions: form.permissions,
|
||||
claudeAccountId: form.claudeAccountId || null,
|
||||
geminiAccountId: form.geminiAccountId || null
|
||||
}
|
||||
|
||||
// 模型限制
|
||||
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
|
||||
data.restrictedModels = form.restrictedModels
|
||||
} else {
|
||||
data.restrictedModels = []
|
||||
}
|
||||
|
||||
// 客户端限制
|
||||
if (form.enableClientRestriction && form.allowedClients.length > 0) {
|
||||
data.allowedClients = form.allowedClients
|
||||
} else {
|
||||
data.allowedClients = []
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '更新失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化表单数据
|
||||
onMounted(async () => {
|
||||
// 加载支持的客户端
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
form.tokenLimit = props.apiKey.tokenLimit || ''
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
|
||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
form.enableModelRestriction = form.restrictedModels.length > 0
|
||||
form.enableClientRestriction = form.allowedClients.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
155
web/admin-spa/src/components/apikeys/NewApiKeyModal.vue
Normal file
155
web/admin-spa/src/components/apikeys/NewApiKeyModal.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-check text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-shield-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-1">请妥善保管您的 API Key</h4>
|
||||
<p class="text-sm text-gray-600">API Key 只会显示一次,关闭此窗口后将无法再次查看完整密钥。请立即复制并保存到安全的地方。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key 信息 -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">名称</label>
|
||||
<p class="text-gray-900">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述</label>
|
||||
<p class="text-gray-600 text-sm">{{ apiKey.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="showFullKey ? 'text' : 'password'"
|
||||
:value="apiKey.key"
|
||||
readonly
|
||||
class="form-input w-full pr-24 font-mono text-sm bg-gray-50"
|
||||
>
|
||||
<div class="absolute right-1 top-1 flex gap-1">
|
||||
<button
|
||||
@click="toggleKeyVisibility"
|
||||
type="button"
|
||||
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors"
|
||||
:title="showFullKey ? '隐藏' : '显示'"
|
||||
>
|
||||
<i :class="showFullKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="copyApiKey"
|
||||
type="button"
|
||||
class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg text-sm transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-500"></i>使用说明
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 space-y-2">
|
||||
<p>1. 在 HTTP 请求头中添加:</p>
|
||||
<code class="block bg-white rounded px-3 py-2 text-xs">Authorization: Bearer {{ apiKey.key }}</code>
|
||||
|
||||
<p class="pt-2">2. 请求示例:</p>
|
||||
<pre class="bg-white rounded px-3 py-2 text-xs overflow-x-auto">curl -X POST {{ currentBaseUrl }}v1/messages \
|
||||
-H "Authorization: Bearer {{ apiKey.key }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "claude-3-opus-20240229", "messages": [...]}'</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="btn btn-primary px-6 py-2.5"
|
||||
>
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const showFullKey = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
// 计算基础 URL
|
||||
const currentBaseUrl = computed(() => {
|
||||
return `${window.location.protocol}//${window.location.host}/api/`
|
||||
})
|
||||
|
||||
// 切换密钥可见性
|
||||
const toggleKeyVisibility = () => {
|
||||
showFullKey.value = !showFullKey.value
|
||||
}
|
||||
|
||||
// 复制 API Key
|
||||
const copyApiKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.apiKey.key)
|
||||
copied.value = true
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
|
||||
// 3秒后重置复制状态
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
224
web/admin-spa/src/components/apikeys/RenewApiKeyModal.vue
Normal file
224
web/admin-spa/src/components/apikeys/RenewApiKeyModal.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-clock text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-info text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-1">API Key 信息</h4>
|
||||
<p class="text-sm text-gray-700">{{ apiKey.name }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
当前过期时间:{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
@change="updateRenewExpireAt"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="7d">延长 7 天</option>
|
||||
<option value="30d">延长 30 天</option>
|
||||
<option value="90d">延长 90 天</option>
|
||||
<option value="180d">延长 180 天</option>
|
||||
<option value="365d">延长 365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
<option value="permanent">设为永不过期</option>
|
||||
</select>
|
||||
<div v-if="form.renewDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
type="datetime-local"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
@change="updateCustomRenewExpireAt"
|
||||
>
|
||||
</div>
|
||||
<p v-if="form.newExpiresAt" class="text-xs text-gray-500 mt-2">
|
||||
新的过期时间:{{ formatExpireDate(form.newExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="renewApiKey"
|
||||
:disabled="loading || !form.renewDuration"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-clock mr-2"></i>
|
||||
{{ loading ? '续期中...' : '确认续期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
renewDuration: '30d',
|
||||
customExpireDate: '',
|
||||
newExpiresAt: null
|
||||
})
|
||||
|
||||
// 计算最小日期时间
|
||||
const minDateTime = computed(() => {
|
||||
const now = new Date()
|
||||
// 如果有当前过期时间且未过期,从当前过期时间开始
|
||||
if (props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > now) {
|
||||
return new Date(props.apiKey.expiresAt).toISOString().slice(0, 16)
|
||||
}
|
||||
// 否则从现在开始
|
||||
now.setMinutes(now.getMinutes() + 1)
|
||||
return now.toISOString().slice(0, 16)
|
||||
})
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新续期后的过期时间
|
||||
const updateRenewExpireAt = () => {
|
||||
if (!form.renewDuration) {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.renewDuration === 'permanent') {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
if (form.renewDuration === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
const baseDate = props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
|
||||
? new Date(props.apiKey.expiresAt)
|
||||
: new Date()
|
||||
|
||||
const duration = form.renewDuration
|
||||
const match = duration.match(/(\d+)([dhmy])/)
|
||||
|
||||
if (match) {
|
||||
const [, value, unit] = match
|
||||
const num = parseInt(value)
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
baseDate.setDate(baseDate.getDate() + num)
|
||||
break
|
||||
case 'h':
|
||||
baseDate.setHours(baseDate.getHours() + num)
|
||||
break
|
||||
case 'm':
|
||||
baseDate.setMonth(baseDate.getMonth() + num)
|
||||
break
|
||||
case 'y':
|
||||
baseDate.setFullYear(baseDate.getFullYear() + num)
|
||||
break
|
||||
}
|
||||
|
||||
form.newExpiresAt = baseDate.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新自定义续期时间
|
||||
const updateCustomRenewExpireAt = () => {
|
||||
if (form.customExpireDate) {
|
||||
form.newExpiresAt = new Date(form.customExpireDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 续期 API Key
|
||||
const renewApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const data = {
|
||||
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}/renew`, data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 续期成功', 'success')
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '续期失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('续期失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
updateRenewExpireAt()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
269
web/admin-spa/src/components/apistats/ApiKeyInput.vue
Normal file
269
web/admin-spa/src/components/apistats/ApiKeyInput.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<!-- 标题区域 -->
|
||||
<div class="wide-card-title text-center mb-6">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<i class="fas fa-chart-line mr-3"></i>
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
|
||||
<!-- API Key 输入 -->
|
||||
<div class="lg:col-span-3">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
输入您的 API Key
|
||||
</label>
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
class="wide-card-input w-full"
|
||||
@keyup.enter="queryStats"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="hidden lg:block text-sm font-medium mb-2 text-gray-700">
|
||||
|
||||
</label>
|
||||
<button
|
||||
@click="queryStats"
|
||||
:disabled="loading || !apiKey.trim()"
|
||||
class="btn btn-primary btn-query w-full h-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner"></i>
|
||||
<i v-else class="fas fa-search"></i>
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { apiKey, loading } = storeToRefs(apiStatsStore)
|
||||
const { queryStats } = apiStatsStore
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 宽卡片样式 */
|
||||
.api-input-wide-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.api-input-wide-card:hover {
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(0, 0, 0, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.wide-card-title h2 {
|
||||
color: #1f2937;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
color: #4b5563;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.wide-card-title .fas.fa-chart-line {
|
||||
color: #3b82f6;
|
||||
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.api-input-grid {
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.wide-card-input {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
color: #1f2937;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wide-card-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.wide-card-input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96, 165, 250, 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* 查询按钮特定样式 */
|
||||
.btn-query {
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 安全提示样式 */
|
||||
.security-notice {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.security-notice:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.security-notice .fas.fa-shield-alt {
|
||||
color: #10b981;
|
||||
text-shadow: 0 1px 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.api-input-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wide-card-input {
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-query {
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.api-input-wide-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wide-card-input {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-query {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
web/admin-spa/src/components/apistats/LimitConfig.vue
Normal file
173
web/admin-spa/src/components/apistats/LimitConfig.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 限制配置 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
|
||||
限制配置
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">Token 限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">并发限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">速率限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
|
||||
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
|
||||
: '无限制' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">每日费用限制</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">模型限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有模型
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">客户端限制</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="text-orange-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
|
||||
</span>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
允许所有客户端
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细限制信息 -->
|
||||
<div v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
|
||||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
|
||||
class="card p-6 mt-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-list-alt mr-3 text-amber-500"></i>
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 模型限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-amber-800 mb-3 flex items-center">
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="model in statsData.restrictions.restrictedModels"
|
||||
:key="model"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-amber-200">
|
||||
<i class="fas fa-ban mr-2 text-red-500"></i>
|
||||
<span class="text-gray-800">{{ model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-amber-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 客户端限制详情 -->
|
||||
<div v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-bold text-blue-800 mb-3 flex items-center">
|
||||
<i class="fas fa-desktop mr-2"></i>
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="client in statsData.restrictions.allowedClients"
|
||||
:key="client"
|
||||
class="bg-white rounded px-3 py-2 text-sm border border-blue-200">
|
||||
<i class="fas fa-check mr-2 text-green-500"></i>
|
||||
<span class="text-gray-800">{{ client }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 mt-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
173
web/admin-spa/src/components/apistats/ModelUsageStats.vue
Normal file
173
web/admin-spa/src/components/apistats/ModelUsageStats.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold flex items-center text-gray-900">
|
||||
<i class="fas fa-robot mr-3 text-indigo-500"></i>
|
||||
模型使用统计 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计加载状态 -->
|
||||
<div v-if="modelStatsLoading" class="text-center py-8">
|
||||
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600"></i>
|
||||
<p class="text-gray-600">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
<div v-else-if="modelStats.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(model, index) in modelStats"
|
||||
:key="index"
|
||||
class="model-usage-item"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 class="font-bold text-lg text-gray-900">{{ model.model }}</h4>
|
||||
<p class="text-gray-600 text-sm">{{ model.requests }} 次请求</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-green-600">{{ model.formatted?.total || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输入 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.inputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">输出 Token</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.outputTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存创建</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheCreateTokens) }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">缓存读取</div>
|
||||
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheReadTokens) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无模型数据 -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-chart-pie text-3xl mb-3"></i>
|
||||
<p>暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 模型使用项样式 */
|
||||
.model-usage-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-usage-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.model-usage-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
web/admin-spa/src/components/apistats/StatsOverview.vue
Normal file
255
web/admin-spa/src/components/apistats/StatsOverview.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- API Key 基本信息 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
|
||||
API Key 信息
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">名称</span>
|
||||
<span class="font-medium text-gray-900">{{ statsData.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">状态</span>
|
||||
<span :class="statsData.isActive ? 'text-green-600' : 'text-red-600'" class="font-medium">
|
||||
<i :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">权限</span>
|
||||
<span class="font-medium text-gray-900">{{ formatPermissions(statsData.permissions) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">创建时间</span>
|
||||
<span class="font-medium text-gray-900">{{ formatDate(statsData.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600">过期时间</span>
|
||||
<div v-if="statsData.expiresAt">
|
||||
<div v-if="isApiKeyExpired(statsData.expiresAt)" class="text-red-600 font-medium">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
已过期
|
||||
</div>
|
||||
<div v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" class="text-orange-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-900 font-medium">
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 font-medium">
|
||||
<i class="fas fa-infinity mr-1"></i>
|
||||
永不过期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用统计概览 -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-chart-bar mr-3 text-green-500"></i>
|
||||
使用统计概览 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
|
||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '无'
|
||||
|
||||
try {
|
||||
const date = dayjs(dateString)
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
} catch (error) {
|
||||
return '格式错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 API Key 是否已过期
|
||||
const isApiKeyExpired = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
// 检查 API Key 是否即将过期(7天内)
|
||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
const expireDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpire > 0 && daysUntilExpire <= 7
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化权限
|
||||
const formatPermissions = (permissions) => {
|
||||
const permissionMap = {
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
'all': '全部模型'
|
||||
}
|
||||
|
||||
return permissionMap[permissions] || permissions || '未知'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card .text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card .text-3xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card .text-sm {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
web/admin-spa/src/components/apistats/TokenDistribution.vue
Normal file
102
web/admin-spa/src/components/apistats/TokenDistribution.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-coins mr-3 text-yellow-500"></i>
|
||||
Token 使用分布 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-right mr-2 text-green-500"></i>
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-arrow-left mr-2 text-blue-500"></i>
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-save mr-2 text-purple-500"></i>
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-download mr-2 text-orange-500"></i>
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex justify-between items-center font-bold text-gray-900">
|
||||
<span>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
|
||||
<span class="text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
} else {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
193
web/admin-spa/src/components/common/ConfirmDialog.vue
Normal file
193
web/admin-spa/src/components/common/ConfirmDialog.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-white text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<div class="text-gray-600 leading-relaxed whitespace-pre-line">{{ message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="handleCancel"
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
class="btn btn-danger px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isProcessing }"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div v-if="isProcessing" class="loading-spinner mr-2"></div>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 状态
|
||||
const isVisible = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
const confirmText = ref('确认')
|
||||
const cancelText = ref('取消')
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancelTextParam = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
message.value = messageText
|
||||
confirmText.value = confirmTextParam
|
||||
cancelText.value = cancelTextParam
|
||||
isVisible.value = true
|
||||
isProcessing.value = false
|
||||
resolvePromise = resolve
|
||||
})
|
||||
}
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
// 延迟一点时间以显示loading状态
|
||||
setTimeout(() => {
|
||||
isVisible.value = false
|
||||
isProcessing.value = false
|
||||
if (resolvePromise) {
|
||||
resolvePromise(true)
|
||||
resolvePromise = null
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isVisible.value = false
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false)
|
||||
resolvePromise = null
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event) => {
|
||||
if (!isVisible.value) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel()
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
|
||||
handleConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局方法注册
|
||||
onMounted(() => {
|
||||
window.showConfirm = showConfirm
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (window.showConfirm === showConfirm) {
|
||||
delete window.showConfirm
|
||||
}
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 暴露方法供组件使用
|
||||
defineExpose({
|
||||
showConfirm
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e5e7eb;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-4 h-4 border-2 border-gray-300 border-t-white rounded-full animate-spin;
|
||||
}
|
||||
|
||||
/* Modal transitions */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-content,
|
||||
.modal-leave-active .modal-content {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-content,
|
||||
.modal-leave-to .modal-content {
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.modal-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
59
web/admin-spa/src/components/common/ConfirmModal.vue
Normal file
59
web/admin-spa/src/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-exclamation text-white text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '继续'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
81
web/admin-spa/src/components/common/LogoTitle.vue
Normal file
81
web/admin-spa/src/components/common/LogoTitle.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Logo区域 -->
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
|
||||
<template v-if="!loading">
|
||||
<img v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
alt="Logo"
|
||||
class="w-8 h-8 object-contain"
|
||||
@error="handleLogoError">
|
||||
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
|
||||
</template>
|
||||
<div v-else class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex flex-col justify-center min-h-[48px]">
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="!loading && title">
|
||||
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">{{ title }}</h1>
|
||||
</template>
|
||||
<div v-else-if="loading" class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-gray-600 text-sm leading-tight mt-0.5">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
logoSrc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
titleClass: {
|
||||
type: String,
|
||||
default: 'text-gray-900'
|
||||
}
|
||||
})
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleLogoError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 骨架屏动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.header-title {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
56
web/admin-spa/src/components/common/StatCard.vue
Normal file
56
web/admin-spa/src/components/common/StatCard.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ value }}</p>
|
||||
<p v-if="subtitle" class="text-sm text-gray-500 mt-2">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div :class="['stat-icon', iconBgClass]">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const colorMap = {
|
||||
primary: 'bg-gradient-to-br from-blue-500 to-purple-500',
|
||||
success: 'bg-gradient-to-br from-green-500 to-emerald-500',
|
||||
warning: 'bg-gradient-to-br from-yellow-500 to-orange-500',
|
||||
danger: 'bg-gradient-to-br from-red-500 to-pink-500',
|
||||
info: 'bg-gradient-to-br from-cyan-500 to-blue-500'
|
||||
}
|
||||
return colorMap[props.iconColor] || colorMap.primary
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
|
||||
</style>
|
||||
373
web/admin-spa/src/components/common/ToastNotification.vue
Normal file
373
web/admin-spa/src/components/common/ToastNotification.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'toast',
|
||||
`toast-${toast.type}`,
|
||||
toast.isVisible ? 'toast-show' : 'toast-hide'
|
||||
]"
|
||||
@click="removeToast(toast.id)"
|
||||
>
|
||||
<div class="toast-content">
|
||||
<div class="toast-icon">
|
||||
<i :class="getIconClass(toast.type)"></i>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div v-if="toast.title" class="toast-title">{{ toast.title }}</div>
|
||||
<div class="toast-message">{{ toast.message }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="toast-close"
|
||||
@click.stop="removeToast(toast.id)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="toast.duration > 0"
|
||||
class="toast-progress"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 状态
|
||||
const toasts = ref([])
|
||||
let toastIdCounter = 0
|
||||
|
||||
// 获取图标类名
|
||||
const getIconClass = (type) => {
|
||||
const iconMap = {
|
||||
success: 'fas fa-check-circle',
|
||||
error: 'fas fa-exclamation-circle',
|
||||
warning: 'fas fa-exclamation-triangle',
|
||||
info: 'fas fa-info-circle'
|
||||
}
|
||||
return iconMap[type] || iconMap.info
|
||||
}
|
||||
|
||||
// 添加Toast
|
||||
const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
const id = ++toastIdCounter
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
title,
|
||||
duration,
|
||||
isVisible: false
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// 下一帧显示动画
|
||||
setTimeout(() => {
|
||||
toast.isVisible = true
|
||||
}, 10)
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// 移除Toast
|
||||
const removeToast = (id) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index > -1) {
|
||||
const toast = toasts.value[index]
|
||||
toast.isVisible = false
|
||||
|
||||
// 等待动画完成后移除
|
||||
setTimeout(() => {
|
||||
const currentIndex = toasts.value.findIndex(t => t.id === id)
|
||||
if (currentIndex > -1) {
|
||||
toasts.value.splice(currentIndex, 1)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有Toast
|
||||
const clearAllToasts = () => {
|
||||
toasts.value.forEach(toast => {
|
||||
toast.isVisible = false
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
toasts.value.length = 0
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 暴露方法给全局使用
|
||||
const showToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
return addToast(message, type, title, duration)
|
||||
}
|
||||
|
||||
// 全局方法注册
|
||||
onMounted(() => {
|
||||
// 将方法挂载到全局
|
||||
window.showToast = showToast
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理全局方法
|
||||
if (window.showToast === showToast) {
|
||||
delete window.showToast
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法供组件使用
|
||||
defineExpose({
|
||||
showToast,
|
||||
removeToast,
|
||||
clearAllToasts
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toast-show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-hide {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: toast-progress linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Success Toast */
|
||||
.toast-success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #10b981;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
.toast-success .toast-title {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.toast-success .toast-message {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.toast-success .toast-progress {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
/* Error Toast */
|
||||
.toast-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #ef4444;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.toast-error .toast-title {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.toast-error .toast-message {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.toast-error .toast-progress {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* Warning Toast */
|
||||
.toast-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.toast-warning .toast-title {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.toast-warning .toast-message {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.toast-warning .toast-progress {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info Toast */
|
||||
.toast-info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3b82f6;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.toast-info .toast-title {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.toast-info .toast-message {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.toast-info .toast-progress {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast List Transitions */
|
||||
.toast-list-enter-active,
|
||||
.toast-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
134
web/admin-spa/src/components/dashboard/ModelDistribution.vue
Normal file
134
web/admin-spa/src/components/dashboard/ModelDistribution.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<i class="fas fa-robot mr-2 text-purple-500"></i>
|
||||
模型使用分布
|
||||
</h2>
|
||||
|
||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||
<el-radio-button label="daily">今日</el-radio-button>
|
||||
<el-radio-button label="total">累计</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardStore.dashboardModelStats.length === 0" class="text-center py-12 text-gray-500">
|
||||
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30"></i>
|
||||
<p>暂无模型使用数据</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 饼图 -->
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="(stat, index) in sortedStats" :key="stat.model"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-4 h-4 rounded" :style="`background-color: ${getColor(index)}`"></div>
|
||||
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
|
||||
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
|
||||
const modelPeriod = ref('daily')
|
||||
|
||||
const sortedStats = computed(() => {
|
||||
return [...dashboardStore.dashboardModelStats].sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const getColor = (index) => {
|
||||
const { colorSchemes } = useChartConfig()
|
||||
const colors = colorSchemes.primary
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.dashboardModelStats.length) return
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
const { colorSchemes } = useChartConfig()
|
||||
const colors = colorSchemes.primary
|
||||
|
||||
chart = new Chart(chartCanvas.value, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sortedStats.value.map(stat => stat.model),
|
||||
datasets: [{
|
||||
data: sortedStats.value.map(stat => stat.requests),
|
||||
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const stat = sortedStats.value[context.dataIndex]
|
||||
const percentage = ((stat.requests / dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) * 100).toFixed(1)
|
||||
return [
|
||||
`${stat.model}: ${percentage}%`,
|
||||
`请求: ${formatNumber(stat.requests)}`,
|
||||
`Tokens: ${formatNumber(stat.totalTokens)}`
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePeriodChange = async () => {
|
||||
await dashboardStore.loadModelStats(modelPeriod.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.dashboardModelStats, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
167
web/admin-spa/src/components/dashboard/UsageTrend.vue
Normal file
167
web/admin-spa/src/components/dashboard/UsageTrend.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<i class="fas fa-chart-area mr-2 text-blue-500"></i>
|
||||
使用趋势
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||
<el-radio-button label="day">按天</el-radio-button>
|
||||
<el-radio-button label="hour">按小时</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select v-model="trendPeriod" size="small" style="width: 120px" @change="handlePeriodChange">
|
||||
<el-option :label="`最近${period.days}天`" :value="period.days" v-for="period in periodOptions" :key="period.days" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
|
||||
const trendPeriod = ref(7)
|
||||
const granularity = ref('day')
|
||||
|
||||
const periodOptions = [
|
||||
{ days: 1, label: '24小时' },
|
||||
{ days: 7, label: '7天' },
|
||||
{ days: 30, label: '30天' }
|
||||
]
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
const { getGradient } = useChartConfig()
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
const labels = dashboardStore.trendData.map(item => {
|
||||
if (granularity.value === 'hour') {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:00`
|
||||
}
|
||||
return item.date
|
||||
})
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map(item => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
yAxisID: 'y',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map(item => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求次数'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token使用量'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePeriodChange = async () => {
|
||||
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
const handleGranularityChange = async () => {
|
||||
// 根据粒度调整时间范围
|
||||
if (granularity.value === 'hour' && trendPeriod.value > 7) {
|
||||
trendPeriod.value = 1
|
||||
}
|
||||
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.trendData, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
412
web/admin-spa/src/components/layout/AppHeader.vue
Normal file
412
web/admin-spa/src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<!-- 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
subtitle="管理后台"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
title-class="text-white"
|
||||
/>
|
||||
<!-- 版本信息 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
||||
<!-- 更新提示 -->
|
||||
<a
|
||||
v-if="versionInfo.hasUpdate"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<i class="fas fa-arrow-up text-[10px]"></i>
|
||||
<span>新版本</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 用户菜单 -->
|
||||
<div class="relative user-menu-container">
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
class="btn btn-primary px-4 py-3 flex items-center gap-2 relative"
|
||||
>
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<span>{{ currentUser.username || 'Admin' }}</span>
|
||||
<i class="fas fa-chevron-down text-xs transition-transform duration-200" :class="{ 'rotate-180': userMenuOpen }"></i>
|
||||
</button>
|
||||
|
||||
<!-- 悬浮菜单 -->
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
|
||||
style="z-index: 999999;"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 版本信息 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">当前版本</span>
|
||||
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
|
||||
</div>
|
||||
<div v-if="versionInfo.hasUpdate" class="mt-2">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-green-600 font-medium">
|
||||
<i class="fas fa-arrow-up mr-1"></i>有新版本
|
||||
</span>
|
||||
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
|
||||
</div>
|
||||
<a
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-external-link-alt mr-1"></i>查看更新
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="versionInfo.checkingUpdate" class="mt-2 text-center text-xs text-gray-500">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>检查更新中...
|
||||
</div>
|
||||
<div v-else class="mt-2 text-center">
|
||||
<!-- 已是最新版提醒 -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="versionInfo.noUpdateMessage" key="message" class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block">
|
||||
<p class="text-xs text-green-700 font-medium">
|
||||
<i class="fas fa-check-circle mr-1"></i>当前已是最新版本
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
key="button"
|
||||
@click="checkForUpdates()"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>检查更新
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openChangePasswordModal"
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i class="fas fa-key text-blue-500"></i>
|
||||
<span>修改账户信息</span>
|
||||
</button>
|
||||
|
||||
<hr class="my-2 border-gray-200">
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-red-500"></i>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改账户信息模态框 -->
|
||||
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-key text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="closeChangePasswordModal"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="changePassword" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
|
||||
<input
|
||||
:value="currentUser.username || 'Admin'"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">当前用户名,输入新用户名以修改</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新用户名</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入新用户名(留空保持不变)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">留空表示不修改用户名</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入当前密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入新密码"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">密码长度至少8位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">确认新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请再次输入新密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changePasswordLoading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
>
|
||||
<div v-if="changePasswordLoading" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => authStore.user || { username: 'Admin' })
|
||||
|
||||
// OEM设置
|
||||
const oemSettings = computed(() => authStore.oemSettings || {})
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
|
||||
// 版本信息
|
||||
const versionInfo = ref({
|
||||
current: '...',
|
||||
latest: '',
|
||||
hasUpdate: false,
|
||||
checkingUpdate: false,
|
||||
lastChecked: null,
|
||||
releaseInfo: null,
|
||||
noUpdateMessage: false
|
||||
})
|
||||
|
||||
// 用户菜单状态
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
// 修改密码模态框
|
||||
const showChangePasswordModal = ref(false)
|
||||
const changePasswordLoading = ref(false)
|
||||
const changePasswordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
newUsername: ''
|
||||
})
|
||||
|
||||
// 检查更新(同时获取版本信息)
|
||||
const checkForUpdates = async () => {
|
||||
if (versionInfo.value.checkingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
versionInfo.value.checkingUpdate = true
|
||||
|
||||
try {
|
||||
const result = await apiClient.get('/admin/check-updates')
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data
|
||||
|
||||
versionInfo.value.current = data.current
|
||||
versionInfo.value.latest = data.latest
|
||||
versionInfo.value.hasUpdate = data.hasUpdate
|
||||
versionInfo.value.releaseInfo = data.releaseInfo
|
||||
versionInfo.value.lastChecked = new Date()
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('versionInfo', JSON.stringify({
|
||||
current: data.current,
|
||||
latest: data.latest,
|
||||
lastChecked: versionInfo.value.lastChecked,
|
||||
hasUpdate: data.hasUpdate,
|
||||
releaseInfo: data.releaseInfo
|
||||
}))
|
||||
|
||||
// 如果没有更新,显示提醒
|
||||
if (!data.hasUpdate) {
|
||||
versionInfo.value.noUpdateMessage = true
|
||||
// 3秒后自动隐藏提醒
|
||||
setTimeout(() => {
|
||||
versionInfo.value.noUpdateMessage = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
|
||||
// 尝试从localStorage读取缓存的版本信息
|
||||
const cached = localStorage.getItem('versionInfo')
|
||||
if (cached) {
|
||||
const cachedInfo = JSON.parse(cached)
|
||||
versionInfo.value.current = cachedInfo.current || versionInfo.value.current
|
||||
versionInfo.value.latest = cachedInfo.latest
|
||||
versionInfo.value.hasUpdate = cachedInfo.hasUpdate
|
||||
versionInfo.value.releaseInfo = cachedInfo.releaseInfo
|
||||
versionInfo.value.lastChecked = new Date(cachedInfo.lastChecked)
|
||||
}
|
||||
} finally {
|
||||
versionInfo.value.checkingUpdate = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开修改密码弹窗
|
||||
const openChangePasswordModal = () => {
|
||||
changePasswordForm.currentPassword = ''
|
||||
changePasswordForm.newPassword = ''
|
||||
changePasswordForm.confirmPassword = ''
|
||||
changePasswordForm.newUsername = ''
|
||||
showChangePasswordModal.value = true
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// 关闭修改密码弹窗
|
||||
const closeChangePasswordModal = () => {
|
||||
showChangePasswordModal.value = false
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
|
||||
showToast('两次输入的密码不一致', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (changePasswordForm.newPassword.length < 8) {
|
||||
showToast('新密码长度至少8位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
changePasswordLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await apiClient.post('/admin/change-password', {
|
||||
currentPassword: changePasswordForm.currentPassword,
|
||||
newPassword: changePasswordForm.newPassword,
|
||||
newUsername: changePasswordForm.newUsername || undefined
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
const message = changePasswordForm.newUsername ? '账户信息修改成功,请重新登录' : '密码修改成功,请重新登录'
|
||||
showToast(message, 'success')
|
||||
closeChangePasswordModal()
|
||||
|
||||
// 延迟后退出登录
|
||||
setTimeout(() => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} else {
|
||||
showToast(data.message || '修改失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('修改密码失败', 'error')
|
||||
} finally {
|
||||
changePasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
showToast('已安全退出', 'success')
|
||||
}
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (event) => {
|
||||
const userMenuContainer = event.target.closest('.user-menu-container')
|
||||
if (!userMenuContainer && userMenuOpen.value) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
|
||||
// 设置自动检查更新(每小时检查一次)
|
||||
setInterval(() => {
|
||||
checkForUpdates()
|
||||
}, 3600000) // 1小时
|
||||
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 用户菜单样式优化 */
|
||||
.user-menu-dropdown {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* fade过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
71
web/admin-spa/src/components/layout/MainLayout.vue
Normal file
71
web/admin-spa/src/components/layout/MainLayout.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1; min-height: calc(100vh - 240px);">
|
||||
<!-- 标签栏 -->
|
||||
<TabBar :active-tab="activeTab" @tab-change="handleTabChange" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="tab-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<keep-alive :include="['DashboardView', 'ApiKeysView']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 根据路由设置当前激活的标签
|
||||
const activeTab = ref('dashboard')
|
||||
|
||||
const tabRouteMap = {
|
||||
dashboard: '/dashboard',
|
||||
apiKeys: '/api-keys',
|
||||
accounts: '/accounts',
|
||||
tutorial: '/tutorial',
|
||||
settings: '/settings'
|
||||
}
|
||||
|
||||
// 监听路由变化,更新激活的标签
|
||||
watch(() => route.path, (newPath) => {
|
||||
const tabKey = Object.keys(tabRouteMap).find(
|
||||
key => tabRouteMap[key] === newPath
|
||||
)
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabChange = (tabKey) => {
|
||||
activeTab.value = tabKey
|
||||
router.push(tabRouteMap[tabKey])
|
||||
}
|
||||
|
||||
// 加载OEM设置
|
||||
onMounted(() => {
|
||||
authStore.loadOemSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局定义的过渡样式 */
|
||||
</style>
|
||||
38
web/admin-spa/src/components/layout/TabBar.vue
Normal file
38
web/admin-spa/src/components/layout/TabBar.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 mb-6 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="$emit('tab-change', tab.key)"
|
||||
:class="[
|
||||
'tab-btn flex-1 py-3 px-6 text-sm font-semibold transition-all duration-300',
|
||||
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i :class="tab.icon + ' mr-2'"></i>{{ tab.name }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['tab-change'])
|
||||
|
||||
const tabs = [
|
||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' },
|
||||
{ key: 'settings', name: '其他设置', icon: 'fas fa-cogs' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .tab-btn 类 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user