feat: 全新的Vue3管理后台(admin-spa)和路由重构

🎨 新增功能:
- 使用Vue3 + Vite构建的全新管理后台界面
- 支持Tab切换的API统计页面(统计查询/使用教程)
- 优雅的胶囊式Tab切换设计
- 同步了PR #106的会话窗口管理功能
- 完整的响应式设计和骨架屏加载状态

🔧 路由调整:
- 新版管理后台部署在 /admin-next/ 路径
- 将根路径 / 重定向到 /admin-next/api-stats
- 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由
- 将 /apiStats 页面路由重定向到新版,保留API端点

🗑️ 清理工作:
- 删除旧版 web/admin/ 静态文件
- 删除旧版 web/apiStats/ 静态文件
- 清理相关的文件服务代码

🐛 修复问题:
- 修复重定向循环问题
- 修复环境变量配置
- 修复路由404错误
- 优化构建配置

🚀 生成方式:使用 Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-29 12:40:51 +08:00
parent c98de2aca5
commit 414856f152
70 changed files with 18748 additions and 10314 deletions

View File

@@ -0,0 +1,646 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-user-circle text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">{{ isEdit ? '编辑账户' : '添加账户' }}</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 步骤指示器 -->
<div v-if="!isEdit && form.addType === 'oauth'" class="flex items-center justify-center mb-8">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
1
</div>
<span class="ml-2 text-sm font-medium text-gray-700">基本信息</span>
</div>
<div class="w-8 h-0.5 bg-gray-300"></div>
<div class="flex items-center">
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
2
</div>
<span class="ml-2 text-sm font-medium text-gray-700">授权认证</span>
</div>
</div>
</div>
<!-- 步骤1: 基本信息和代理设置 -->
<div v-if="oauthStep === 1 && !isEdit">
<div class="space-y-6">
<div v-if="!isEdit">
<label class="block text-sm font-semibold text-gray-700 mb-3">平台</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.platform"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.platform"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">Gemini</span>
</label>
</div>
</div>
<div v-if="!isEdit">
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.addType"
value="oauth"
class="mr-2"
>
<span class="text-sm text-gray-700">OAuth 授权 (推荐)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.addType"
value="manual"
class="mr-2"
>
<span class="text-sm text-gray-700">手动输入 Access Token</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
<input
v-model="form.name"
type="text"
required
class="form-input w-full"
placeholder="为账户设置一个易识别的名称"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
<textarea
v-model="form.description"
rows="3"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
value="shared"
class="mr-2"
>
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
value="dedicated"
class="mr-2"
>
<span class="text-sm text-gray-700">专属账户</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">
共享账户供所有API Key使用专属账户仅供特定API Key使用
</p>
</div>
<!-- Gemini 项目编号字段 -->
<div v-if="form.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="form.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p>某些 Google 账号特别是绑定了 Google Cloud 的账号会被识别为 Workspace 账号需要提供额外的项目编号</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600"> 注意不要复制项目IDProject ID要复制项目编号</li>
</ol>
</div>
<p class="mt-2"><strong>提示</strong>如果您的账号是普通个人账号未绑定 Google Cloud请留空此字段</p>
</div>
</div>
</div>
</div>
<!-- 手动输入 Token 字段 -->
<div v-if="form.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
<p v-if="form.platform === 'claude'" class="text-sm text-blue-800 mb-2">
请输入有效的 Claude Access Token如果您有 Refresh Token建议也一并填写以支持自动刷新
</p>
<p v-else-if="form.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
请输入有效的 Gemini Access Token如果您有 Refresh Token建议也一并填写以支持自动刷新
</p>
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
<p class="text-sm text-blue-900 font-medium mb-1">
<i class="fas fa-folder-open mr-1"></i>
获取 Access Token 的方法
</p>
<p v-if="form.platform === 'claude'" class="text-xs text-blue-800">
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证
请勿使用 Claude 官网 API Keys 页面的密钥
</p>
<p v-else-if="form.platform === 'gemini'" class="text-xs text-blue-800">
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code>
</p>
</div>
<p class="text-xs text-blue-600">💡 如果未填写 Refresh TokenToken 过期后需要手动更新</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">Access Token *</label>
<textarea
v-model="form.accessToken"
rows="4"
required
class="form-input w-full resize-none font-mono text-xs"
placeholder="请输入 Access Token..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">Refresh Token (可选)</label>
<textarea
v-model="form.refreshToken"
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="请输入 Refresh Token..."
></textarea>
</div>
</div>
<!-- 代理设置 -->
<ProxyConfig v-model="form.proxy" />
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
v-if="form.addType === 'oauth'"
type="button"
@click="nextStep"
:disabled="!canProceed"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
下一步
</button>
<button
v-else
type="button"
@click="createAccount"
:disabled="loading || !canCreate"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</div>
</div>
<!-- 步骤2: OAuth授权 -->
<OAuthFlow
v-if="oauthStep === 2 && form.addType === 'oauth'"
:platform="form.platform"
:proxy="form.proxy"
@success="handleOAuthSuccess"
@back="oauthStep = 1"
/>
<!-- 编辑模式 -->
<div v-if="isEdit" class="space-y-6">
<!-- 基本信息 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
<input
v-model="form.name"
type="text"
required
class="form-input w-full"
placeholder="为账户设置一个易识别的名称"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">描述 (可选)</label>
<textarea
v-model="form.description"
rows="3"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
value="shared"
class="mr-2"
>
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
value="dedicated"
class="mr-2"
>
<span class="text-sm text-gray-700">专属账户</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">
共享账户供所有API Key使用专属账户仅供特定API Key使用
</p>
</div>
<!-- Gemini 项目编号字段 -->
<div v-if="form.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="form.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<p class="text-xs text-gray-500 mt-2">
Google Cloud/Workspace 账号可能需要提供项目编号
</p>
</div>
<!-- Token 更新 -->
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-key text-white text-sm"></i>
</div>
<div>
<h5 class="font-semibold text-amber-900 mb-2">更新 Token</h5>
<p class="text-sm text-amber-800 mb-2">可以更新 Access Token Refresh Token为了安全起见不会显示当前的 Token </p>
<p class="text-xs text-amber-600">💡 留空表示不更新该字段</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Access Token</label>
<textarea
v-model="form.accessToken"
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新的 Refresh Token</label>
<textarea
v-model="form.refreshToken"
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
></textarea>
</div>
</div>
</div>
<!-- 代理设置 -->
<ProxyConfig v-model="form.proxy" />
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="button"
@click="updateAccount"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
{{ loading ? '更新中...' : '更新' }}
</button>
</div>
</div>
</div>
</div>
<!-- 确认弹窗 -->
<ConfirmModal
:show="showConfirmModal"
:title="confirmOptions.title"
:message="confirmOptions.message"
:confirm-text="confirmOptions.confirmText"
:cancel-text="confirmOptions.cancelText"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { showToast } from '@/utils/toast'
import { useAccountsStore } from '@/stores/accounts'
import { useConfirm } from '@/composables/useConfirm'
import ProxyConfig from './ProxyConfig.vue'
import OAuthFlow from './OAuthFlow.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const props = defineProps({
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'success'])
const accountsStore = useAccountsStore()
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
// 是否为编辑模式
const isEdit = computed(() => !!props.account)
const show = ref(true)
// OAuth步骤
const oauthStep = ref(1)
const loading = ref(false)
// 表单数据
const form = ref({
platform: props.account?.platform || 'claude',
addType: 'oauth',
name: props.account?.name || '',
description: props.account?.description || '',
accountType: props.account?.accountType || 'shared',
projectId: props.account?.projectId || '',
accessToken: '',
refreshToken: '',
proxy: props.account?.proxy || {
enabled: false,
type: 'socks5',
host: '',
port: '',
username: '',
password: ''
}
})
// 计算是否可以进入下一步
const canProceed = computed(() => {
return form.value.name && form.value.platform
})
// 计算是否可以创建
const canCreate = computed(() => {
if (form.value.addType === 'manual') {
return form.value.name && form.value.accessToken
}
return form.value.name
})
// 下一步
const nextStep = async () => {
if (!canProceed.value) {
if (!form.value.name || form.value.name.trim() === '') {
showToast('请填写账户名称', 'error')
}
return
}
// 对于Gemini账户检查项目编号
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
if (!form.value.projectId || form.value.projectId.trim() === '') {
// 使用自定义确认弹窗
const confirmed = await showConfirm(
'项目编号未填写',
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号需要提供项目编号。\n如果您使用的是普通个人账号可以继续不填写。',
'继续',
'返回填写'
)
if (!confirmed) {
return
}
}
}
oauthStep.value = 2
}
// 处理OAuth成功
const handleOAuthSuccess = async (tokenInfo) => {
loading.value = true
try {
const data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
accessToken: tokenInfo.access_token,
refreshToken: tokenInfo.refresh_token,
scopes: tokenInfo.scopes || [],
proxy: form.value.proxy.enabled ? form.value.proxy : null
}
if (form.value.platform === 'gemini' && form.value.projectId) {
data.projectId = form.value.projectId
}
let result
if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
showToast('账户创建成功', 'success')
emit('success', result)
} catch (error) {
showToast(error.message || '账户创建失败', 'error')
} finally {
loading.value = false
}
}
// 创建账户(手动模式)
const createAccount = async () => {
if (!canCreate.value) {
if (!form.value.name || form.value.name.trim() === '') {
showToast('请填写账户名称', 'error')
} else if (!form.value.accessToken || form.value.accessToken.trim() === '') {
showToast('请填写 Access Token', 'error')
}
return
}
loading.value = true
try {
const data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
accessToken: form.value.accessToken,
refreshToken: form.value.refreshToken || undefined,
proxy: form.value.proxy.enabled ? form.value.proxy : null
}
if (form.value.platform === 'gemini' && form.value.projectId) {
data.projectId = form.value.projectId
}
let result
if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
showToast('账户创建成功', 'success')
emit('success', result)
} catch (error) {
showToast(error.message || '账户创建失败', 'error')
} finally {
loading.value = false
}
}
// 更新账户
const updateAccount = async () => {
// 对于Gemini账户检查项目编号
if (form.value.platform === 'gemini') {
if (!form.value.projectId || form.value.projectId.trim() === '') {
// 使用自定义确认弹窗
const confirmed = await showConfirm(
'项目编号未填写',
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号需要提供项目编号。\n如果您使用的是普通个人账号可以继续不填写。',
'继续保存',
'返回填写'
)
if (!confirmed) {
return
}
}
}
loading.value = true
try {
const data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
proxy: form.value.proxy.enabled ? form.value.proxy : null
}
// 只有非空时才更新token
if (form.value.accessToken) {
data.accessToken = form.value.accessToken
}
if (form.value.refreshToken) {
data.refreshToken = form.value.refreshToken
}
if (props.account.platform === 'gemini' && form.value.projectId) {
data.projectId = form.value.projectId
}
if (props.account.platform === 'claude') {
await accountsStore.updateClaudeAccount(props.account.id, data)
} else {
await accountsStore.updateGeminiAccount(props.account.id, data)
}
emit('success')
} catch (error) {
showToast(error.message || '账户更新失败', 'error')
} finally {
loading.value = false
}
}
// 监听账户变化,更新表单
watch(() => props.account, (newAccount) => {
if (newAccount) {
form.value = {
platform: newAccount.platform,
addType: 'oauth',
name: newAccount.name,
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
projectId: newAccount.projectId || '',
accessToken: '',
refreshToken: '',
proxy: newAccount.proxy || {
enabled: false,
type: 'socks5',
host: '',
port: '',
username: '',
password: ''
}
}
}
}, { immediate: true })
</script>