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:
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
@@ -0,0 +1,923 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
|
||||
<p class="text-gray-600">管理和监控您的 API 密钥</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Token统计时间范围选择 -->
|
||||
<select
|
||||
v-model="apiKeyStatsTimeRange"
|
||||
@change="loadApiKeys()"
|
||||
class="form-input px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="today">今日</option>
|
||||
<option value="7days">最近7天</option>
|
||||
<option value="monthly">本月</option>
|
||||
<option value="all">全部时间</option>
|
||||
</select>
|
||||
<button
|
||||
@click.stop="openCreateApiKeyModal"
|
||||
class="btn btn-primary px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i>创建新 Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="apiKeysLoading" class="text-center py-12">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">正在加载 API Keys...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="apiKeys.length === 0" class="text-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-gray-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-gray-500 text-lg">暂无 API Keys</p>
|
||||
<p class="text-gray-400 text-sm mt-2">点击上方按钮创建您的第一个 API Key</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
|
||||
名称
|
||||
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
|
||||
状态
|
||||
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
使用统计
|
||||
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
|
||||
(费用
|
||||
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
|
||||
创建时间
|
||||
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
|
||||
过期时间
|
||||
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200/50">
|
||||
<template v-for="key in sortedApiKeys" :key="key.id">
|
||||
<!-- API Key 主行 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-key text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ key.id }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="key.claudeAccountId">
|
||||
<i class="fas fa-link mr-1"></i>
|
||||
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-share-alt mr-1"></i>
|
||||
使用共享池
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
|
||||
{{ (key.apiKey || '').substring(0, 20) }}...
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||
<div :class="['w-2 h-2 rounded-full mr-2',
|
||||
key.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
{{ key.isActive ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<!-- 请求统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">请求数:</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
|
||||
</div>
|
||||
<!-- Token统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Token:</span>
|
||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- 费用统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">费用:</span>
|
||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
||||
</div>
|
||||
<!-- 每日费用限制 -->
|
||||
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">今日费用:</span>
|
||||
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
|
||||
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 并发限制 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">并发限制:</span>
|
||||
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
|
||||
</div>
|
||||
<!-- 当前并发数 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">当前并发:</span>
|
||||
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
|
||||
{{ key.currentConcurrency || 0 }}
|
||||
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 时间窗口限流 -->
|
||||
<div v-if="key.rateLimitWindow > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">时间窗口:</span>
|
||||
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
|
||||
</div>
|
||||
<!-- 请求次数限制 -->
|
||||
<div v-if="key.rateLimitRequests > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">请求限制:</span>
|
||||
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} 次/窗口</span>
|
||||
</div>
|
||||
<!-- 输入/输出Token -->
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
|
||||
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- 缓存Token细节 -->
|
||||
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
|
||||
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
|
||||
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
|
||||
</div>
|
||||
<!-- RPM/TPM -->
|
||||
<div class="flex justify-between text-xs text-blue-600">
|
||||
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
|
||||
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
|
||||
</div>
|
||||
<!-- 今日统计 -->
|
||||
<div class="pt-1 border-t border-gray-100">
|
||||
<div class="flex justify-between text-xs text-green-600">
|
||||
<span>今日: {{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }}次</span>
|
||||
<span>{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}T</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型分布按钮 -->
|
||||
<div class="pt-2">
|
||||
<button @click="toggleApiKeyModelStats(key.id)" v-if="key && key.id" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']"></i>
|
||||
模型使用分布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div v-if="key.expiresAt">
|
||||
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
已过期
|
||||
</div>
|
||||
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(key.expiresAt) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-600">
|
||||
{{ formatExpireDate(key.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">
|
||||
<i class="fas fa-infinity mr-1"></i>
|
||||
永不过期
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="copyApiStatsLink(key)"
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
|
||||
title="复制统计页面链接"
|
||||
>
|
||||
<i class="fas fa-chart-bar mr-1"></i>统计
|
||||
</button>
|
||||
<button
|
||||
@click="openEditApiKeyModal(key)"
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
|
||||
@click="openRenewApiKeyModal(key)"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>续期
|
||||
</button>
|
||||
<button
|
||||
@click="deleteApiKey(key.id)"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 模型统计展开区域 -->
|
||||
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
||||
<td colspan="7" class="px-6 py-4 bg-gray-50">
|
||||
<div v-if="!apiKeyModelStats[key.id]" class="text-center py-4">
|
||||
<div class="loading-spinner mx-auto"></div>
|
||||
<p class="text-sm text-gray-500 mt-2">加载模型统计...</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- 通用的标题和时间筛选器,无论是否有数据都显示 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h5 class="text-sm font-semibold text-gray-700 flex items-center">
|
||||
<i class="fas fa-chart-pie text-indigo-500 mr-2"></i>
|
||||
模型使用分布
|
||||
</h5>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
|
||||
{{ apiKeyModelStats[key.id].length }} 个模型
|
||||
</span>
|
||||
|
||||
<!-- API Keys日期筛选器 -->
|
||||
<div class="flex gap-1 items-center">
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded p-1">
|
||||
<button
|
||||
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
|
||||
:key="option.value"
|
||||
@click="setApiKeyDateFilterPreset(option.value, key.id)"
|
||||
:class="[
|
||||
'px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
getApiKeyDateFilter(key.id).preset === option.value && getApiKeyDateFilter(key.id).type === 'preset'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Element Plus 日期范围选择器 -->
|
||||
<el-date-picker
|
||||
:model-value="getApiKeyDateFilter(key.id).customRange"
|
||||
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disabledDate"
|
||||
:default-time="defaultTime"
|
||||
size="small"
|
||||
style="width: 280px;"
|
||||
class="api-key-date-picker"
|
||||
:clearable="true"
|
||||
:unlink-panels="false"
|
||||
></el-date-picker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0" class="text-center py-8">
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<i class="fas fa-chart-line text-gray-400 text-lg"></i>
|
||||
<p class="text-sm text-gray-500">暂无模型使用数据</p>
|
||||
<button
|
||||
@click="resetApiKeyDateFilter(key.id)"
|
||||
class="text-blue-500 hover:text-blue-700 text-sm ml-2 flex items-center gap-1 transition-colors"
|
||||
title="重置筛选条件并刷新"
|
||||
>
|
||||
<i class="fas fa-sync-alt text-xs"></i>
|
||||
<span class="text-xs">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p>
|
||||
</div>
|
||||
<div v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="stat in apiKeyModelStats[key.id]" :key="stat.model"
|
||||
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-semibold text-gray-800 block mb-1">{{ stat.model }}</span>
|
||||
<span class="text-xs text-gray-500 bg-blue-50 px-2 py-1 rounded-full">{{ stat.requests }} 次请求</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-coins text-yellow-500 mr-1 text-xs"></i>
|
||||
总Token:
|
||||
</span>
|
||||
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600 flex items-center">
|
||||
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs"></i>
|
||||
费用:
|
||||
</span>
|
||||
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
|
||||
</div>
|
||||
<div class="pt-2 mt-2 border-t border-gray-100">
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-arrow-down text-green-500 mr-1"></i>
|
||||
输入:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-arrow-up text-blue-500 mr-1"></i>
|
||||
输出:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
|
||||
</div>
|
||||
<div v-if="stat.cacheCreateTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-save mr-1"></i>
|
||||
缓存创建:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
|
||||
</div>
|
||||
<div v-if="stat.cacheReadTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
缓存读取:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-3">
|
||||
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
|
||||
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right mt-1">
|
||||
<span class="text-xs font-medium text-indigo-600">
|
||||
{{ calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总计统计,仅在有数据时显示 -->
|
||||
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-semibold text-gray-700 flex items-center">
|
||||
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
|
||||
总计统计
|
||||
</span>
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-gray-600">
|
||||
总请求: <span class="font-semibold text-gray-800">{{ apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) }}</span>
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
总Token: <span class="font-semibold text-gray-800">{{ formatNumber(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<CreateApiKeyModal
|
||||
v-if="showCreateApiKeyModal"
|
||||
:accounts="accounts"
|
||||
@close="showCreateApiKeyModal = false"
|
||||
@success="handleCreateSuccess"
|
||||
/>
|
||||
|
||||
<EditApiKeyModal
|
||||
v-if="showEditApiKeyModal"
|
||||
:apiKey="editingApiKey"
|
||||
:accounts="accounts"
|
||||
@close="showEditApiKeyModal = false"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
|
||||
<RenewApiKeyModal
|
||||
v-if="showRenewApiKeyModal"
|
||||
:apiKey="renewingApiKey"
|
||||
@close="showRenewApiKeyModal = false"
|
||||
@success="handleRenewSuccess"
|
||||
/>
|
||||
|
||||
<NewApiKeyModal
|
||||
v-if="showNewApiKeyModal"
|
||||
:apiKeyData="newApiKeyData"
|
||||
@close="showNewApiKeyModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
||||
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
||||
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||
|
||||
// 响应式数据
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeys = ref([])
|
||||
const apiKeysLoading = ref(false)
|
||||
const apiKeyStatsTimeRange = ref('today')
|
||||
const apiKeysSortBy = ref('')
|
||||
const apiKeysSortOrder = ref('asc')
|
||||
const expandedApiKeys = ref({})
|
||||
const apiKeyModelStats = ref({})
|
||||
const apiKeyDateFilters = ref({})
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
const accounts = ref({ claude: [], gemini: [] })
|
||||
|
||||
// 模态框状态
|
||||
const showCreateApiKeyModal = ref(false)
|
||||
const showEditApiKeyModal = ref(false)
|
||||
const showRenewApiKeyModal = ref(false)
|
||||
const showNewApiKeyModal = ref(false)
|
||||
const editingApiKey = ref(null)
|
||||
const renewingApiKey = ref(null)
|
||||
const newApiKeyData = ref(null)
|
||||
|
||||
// 计算排序后的API Keys
|
||||
const sortedApiKeys = computed(() => {
|
||||
if (!apiKeysSortBy.value) return apiKeys.value
|
||||
|
||||
const sorted = [...apiKeys.value].sort((a, b) => {
|
||||
let aVal = a[apiKeysSortBy.value]
|
||||
let bVal = b[apiKeysSortBy.value]
|
||||
|
||||
// 处理特殊排序字段
|
||||
if (apiKeysSortBy.value === 'status') {
|
||||
aVal = a.isActive ? 1 : 0
|
||||
bVal = b.isActive ? 1 : 0
|
||||
} else if (apiKeysSortBy.value === 'cost') {
|
||||
aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
|
||||
bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
|
||||
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
|
||||
aVal = aVal ? new Date(aVal).getTime() : 0
|
||||
bVal = bVal ? new Date(bVal).getTime() : 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, geminiData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
])
|
||||
|
||||
if (claudeData.success) {
|
||||
accounts.value.claude = claudeData.data || []
|
||||
}
|
||||
|
||||
if (geminiData.success) {
|
||||
accounts.value.gemini = geminiData.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载API Keys
|
||||
const loadApiKeys = async () => {
|
||||
apiKeysLoading.value = true
|
||||
try {
|
||||
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
|
||||
if (data.success) {
|
||||
apiKeys.value = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载 API Keys 失败', 'error')
|
||||
} finally {
|
||||
apiKeysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
if (apiKeysSortBy.value === field) {
|
||||
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
apiKeysSortBy.value = field
|
||||
apiKeysSortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算API Key费用
|
||||
const calculateApiKeyCost = (usage) => {
|
||||
if (!usage || !usage.total) return '$0.0000'
|
||||
const cost = usage.total.cost || 0
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
|
||||
// 获取绑定账户名称
|
||||
const getBoundAccountName = (accountId) => {
|
||||
if (!accountId) return '未知账户'
|
||||
|
||||
// 从Claude账户列表中查找
|
||||
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
|
||||
if (claudeAccount) {
|
||||
return claudeAccount.name
|
||||
}
|
||||
|
||||
// 从Gemini账户列表中查找
|
||||
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
|
||||
if (geminiAccount) {
|
||||
return geminiAccount.name
|
||||
}
|
||||
|
||||
// 如果找不到,返回账户ID的前8位
|
||||
return `账户-${accountId.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// 检查API Key是否过期
|
||||
const isApiKeyExpired = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
// 检查API Key是否即将过期
|
||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||
if (!expiresAt || isApiKeyExpired(expiresAt)) return false
|
||||
const daysUntilExpiry = (new Date(expiresAt) - new Date()) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpiry <= 7
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 切换模型统计展开状态
|
||||
const toggleApiKeyModelStats = async (keyId) => {
|
||||
if (!expandedApiKeys.value[keyId]) {
|
||||
expandedApiKeys.value[keyId] = true
|
||||
// 初始化日期筛选器
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
// 加载模型统计数据
|
||||
await loadApiKeyModelStats(keyId, true)
|
||||
} else {
|
||||
expandedApiKeys.value[keyId] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 API Key 的模型统计
|
||||
const loadApiKeyModelStats = async (keyId, forceReload = false) => {
|
||||
if (!forceReload && apiKeyModelStats.value[keyId] && apiKeyModelStats.value[keyId].length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
try {
|
||||
let url = `/admin/api-keys/${keyId}/model-stats`
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filter.customStart && filter.customEnd) {
|
||||
params.append('startDate', filter.customStart)
|
||||
params.append('endDate', filter.customEnd)
|
||||
params.append('period', 'custom')
|
||||
} else {
|
||||
const period = filter.preset === 'today' ? 'daily' : 'monthly'
|
||||
params.append('period', period)
|
||||
}
|
||||
|
||||
url += '?' + params.toString()
|
||||
|
||||
const data = await apiClient.get(url)
|
||||
if (data.success) {
|
||||
apiKeyModelStats.value[keyId] = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载模型统计失败', 'error')
|
||||
apiKeyModelStats.value[keyId] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 计算API Key模型使用百分比
|
||||
const calculateApiKeyModelPercentage = (value, stats) => {
|
||||
const total = stats.reduce((sum, stat) => sum + (stat.allTokens || 0), 0)
|
||||
if (total === 0) return 0
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
// 计算单个模型费用
|
||||
const calculateModelCost = (stat) => {
|
||||
// 优先使用后端返回的费用数据
|
||||
if (stat.formatted && stat.formatted.total) {
|
||||
return stat.formatted.total
|
||||
}
|
||||
|
||||
// 如果没有 formatted 数据,尝试使用 cost 字段
|
||||
if (stat.cost !== undefined) {
|
||||
return `$${stat.cost.toFixed(6)}`
|
||||
}
|
||||
|
||||
// 默认返回
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
// 初始化API Key的日期筛选器
|
||||
const initApiKeyDateFilter = (keyId) => {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6) // 7天前
|
||||
|
||||
apiKeyDateFilters.value[keyId] = {
|
||||
type: 'preset',
|
||||
preset: '7days',
|
||||
customStart: startDate.toISOString().split('T')[0],
|
||||
customEnd: today.toISOString().split('T')[0],
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API Key的日期筛选器状态
|
||||
const getApiKeyDateFilter = (keyId) => {
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
return apiKeyDateFilters.value[keyId]
|
||||
}
|
||||
|
||||
// 设置 API Key 日期预设
|
||||
const setApiKeyDateFilterPreset = (preset, keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
filter.type = 'preset'
|
||||
filter.preset = preset
|
||||
|
||||
const option = filter.presetOptions.find(opt => opt.value === preset)
|
||||
if (option) {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - (option.days - 1))
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' 00:00:00'
|
||||
}
|
||||
|
||||
filter.customRange = [
|
||||
formatDate(startDate),
|
||||
formatDate(today)
|
||||
]
|
||||
}
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
}
|
||||
|
||||
// API Key 自定义日期范围变化
|
||||
const onApiKeyCustomDateRangeChange = (keyId, value) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
if (value && value.length === 2) {
|
||||
filter.type = 'custom'
|
||||
filter.preset = ''
|
||||
filter.customRange = value
|
||||
filter.customStart = value[0].split(' ')[0]
|
||||
filter.customEnd = value[1].split(' ')[0]
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认7天
|
||||
setApiKeyDateFilterPreset('7days', keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用未来日期
|
||||
const disabledDate = (date) => {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
// 重置API Key日期筛选器
|
||||
const resetApiKeyDateFilter = (keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
// 重置为默认的7天
|
||||
filter.type = 'preset'
|
||||
filter.preset = '7days'
|
||||
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6)
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
filter.customRange = null
|
||||
|
||||
// 重新加载数据
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
showToast('已重置筛选条件并刷新数据', 'info')
|
||||
}
|
||||
|
||||
// 打开创建模态框
|
||||
const openCreateApiKeyModal = () => {
|
||||
showCreateApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
const openEditApiKeyModal = (apiKey) => {
|
||||
editingApiKey.value = apiKey
|
||||
showEditApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开续期模态框
|
||||
const openRenewApiKeyModal = (apiKey) => {
|
||||
renewingApiKey.value = apiKey
|
||||
showRenewApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 处理创建成功
|
||||
const handleCreateSuccess = (data) => {
|
||||
showCreateApiKeyModal.value = false
|
||||
newApiKeyData.value = data
|
||||
showNewApiKeyModal.value = true
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理编辑成功
|
||||
const handleEditSuccess = () => {
|
||||
showEditApiKeyModal.value = false
|
||||
showToast('API Key 更新成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理续期成功
|
||||
const handleRenewSuccess = () => {
|
||||
showRenewApiKeyModal.value = false
|
||||
showToast('API Key 续期成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('确定要删除这个 API Key 吗?此操作不可恢复。')) return
|
||||
|
||||
try {
|
||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
||||
if (data.success) {
|
||||
showToast('API Key 已删除', 'success')
|
||||
loadApiKeys()
|
||||
} else {
|
||||
showToast(data.message || '删除失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制API统计页面链接
|
||||
const copyApiStatsLink = (apiKey) => {
|
||||
// 构建统计页面的完整URL
|
||||
const baseUrl = window.location.origin
|
||||
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
|
||||
|
||||
// 使用传统的textarea方法复制到剪贴板
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = statsUrl
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, 99999) // 兼容移动端
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showToast(`已复制统计页面链接`, 'success')
|
||||
} else {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.error('复制错误:', err)
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载所有需要的数据
|
||||
await Promise.all([
|
||||
clientsStore.loadSupportedClients(),
|
||||
loadAccounts(),
|
||||
loadApiKeys()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-input__inner) {
|
||||
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-range-separator) {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
</style>
|
||||
384
web/admin-spa/src/views/ApiStatsView.vue
Normal file
384
web/admin-spa/src/views/ApiStatsView.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="min-h-screen gradient-bg p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link to="/dashboard" class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-sm"></i>
|
||||
<span class="text-sm font-medium">管理后台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20">
|
||||
<button
|
||||
@click="currentTab = 'stats'"
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'stats' ? 'active' : ''
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-chart-line mr-2"></i>
|
||||
<span>统计查询</span>
|
||||
</button>
|
||||
<button
|
||||
@click="currentTab = 'tutorial'"
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'tutorial' ? 'active' : ''
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-graduation-cap mr-2"></i>
|
||||
<span>使用教程</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计内容 -->
|
||||
<div v-if="currentTab === 'stats'" class="tab-content">
|
||||
<!-- API Key 输入区域 -->
|
||||
<ApiKeyInput />
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="mb-8">
|
||||
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据展示区域 -->
|
||||
<div v-if="statsData" class="fade-in">
|
||||
<div class="glass-strong rounded-3xl p-6 shadow-xl">
|
||||
<!-- 时间范围选择器 -->
|
||||
<div class="mb-6 pb-6 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-clock text-blue-500 text-lg"></i>
|
||||
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="switchPeriod('daily')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
@click="switchPeriod('monthly')"
|
||||
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
|
||||
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
>
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
本月
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息和统计概览 -->
|
||||
<StatsOverview />
|
||||
|
||||
<!-- Token 分布和限制配置 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<TokenDistribution />
|
||||
<LimitConfig />
|
||||
</div>
|
||||
|
||||
<!-- 模型使用统计 -->
|
||||
<ModelUsageStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 教程内容 -->
|
||||
<div v-if="currentTab === 'tutorial'" class="tab-content">
|
||||
<div class="glass-strong rounded-3xl shadow-xl">
|
||||
<TutorialView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
||||
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
||||
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
||||
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||
import TutorialView from './TutorialView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
|
||||
// 当前标签页
|
||||
const currentTab = ref('stats')
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
apiId,
|
||||
loading,
|
||||
modelStatsLoading,
|
||||
oemLoading,
|
||||
error,
|
||||
statsPeriod,
|
||||
statsData,
|
||||
oemSettings
|
||||
} = storeToRefs(apiStatsStore)
|
||||
|
||||
const {
|
||||
queryStats,
|
||||
switchPeriod,
|
||||
loadStatsWithApiId,
|
||||
loadOemSettings,
|
||||
reset
|
||||
} = apiStatsStore
|
||||
|
||||
// 处理键盘快捷键
|
||||
const handleKeyDown = (event) => {
|
||||
// Ctrl/Cmd + Enter 查询
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
if (!loading.value && apiKey.value.trim()) {
|
||||
queryStats()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// ESC 清除数据
|
||||
if (event.key === 'Escape') {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
console.log('API Stats Page loaded')
|
||||
|
||||
// 加载 OEM 设置
|
||||
loadOemSettings()
|
||||
|
||||
// 检查 URL 参数
|
||||
const urlApiId = route.query.apiId
|
||||
const urlApiKey = route.query.apiKey
|
||||
|
||||
if (urlApiId && urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||
apiId.value = urlApiId
|
||||
loadStatsWithApiId()
|
||||
} else if (urlApiKey && urlApiKey.length > 10) {
|
||||
// 向后兼容,支持 apiKey 参数
|
||||
apiKey.value = urlApiKey
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
// 监听 API Key 变化
|
||||
watch(apiKey, (newValue) => {
|
||||
if (!newValue) {
|
||||
apiStatsStore.clearData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 渐变背景 */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gradient-bg::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 标题渐变 */
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* 管理后台按钮 */
|
||||
.admin-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.3), 0 2px 4px -1px rgba(102, 126, 234, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.admin-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(102, 126, 234, 0.4), 0 4px 6px -2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.admin-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* 时间范围按钮 */
|
||||
.period-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.period-btn:not(.active) {
|
||||
color: #374151;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.period-btn:not(.active):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Tab 胶囊按钮样式 */
|
||||
.tab-pill-button {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-pill-button:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab-pill-button.active {
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tab-pill-button i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Tab 内容切换动画 */
|
||||
.tab-content {
|
||||
animation: tabFadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
744
web/admin-spa/src/views/DashboardView.vue
Normal file
744
web/admin-spa/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,744 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 主要统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">总API Keys</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
活跃: {{ dashboardData.activeAccounts || 0 }}
|
||||
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
|
||||
| 限流: {{ dashboardData.rateLimitedAccounts }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">今日请求</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">系统状态</p>
|
||||
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formattedUptime }}</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 mr-8">
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
|
||||
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
|
||||
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="flex justify-between items-center flex-wrap gap-x-4">
|
||||
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.todayInputTokens || 0) }}</span></span>
|
||||
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.todayOutputTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
|
||||
<i class="fas fa-coins"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 mr-8">
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
|
||||
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
|
||||
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
|
||||
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="flex justify-between items-center flex-wrap gap-x-4">
|
||||
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.totalInputTokens || 0) }}</span></span>
|
||||
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.totalOutputTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
|
||||
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">平均RPM</p>
|
||||
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟请求数</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">平均TPM</p>
|
||||
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">每分钟Token数</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
|
||||
<i class="fas fa-rocket"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型消费统计 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">模型使用分布与Token使用趋势</h3>
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
v-for="option in dateFilter.presetOptions"
|
||||
:key="option.value"
|
||||
@click="setDateFilterPreset(option.value)"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
dateFilter.preset === option.value && dateFilter.type === 'preset'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换按钮 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
@click="setTrendGranularity('day')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'day'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-calendar-day mr-1"></i>按天
|
||||
</button>
|
||||
<button
|
||||
@click="setTrendGranularity('hour')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'hour'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>按小时
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Element Plus 日期范围选择器 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<el-date-picker
|
||||
:default-time="defaultTime"
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
style="width: 400px;"
|
||||
class="custom-date-picker"
|
||||
></el-date-picker>
|
||||
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
|
||||
<i class="fas fa-info-circle"></i> 最多24小时
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-sync-alt"></i>刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 饼图 -->
|
||||
<div class="card p-6">
|
||||
<h4 class="text-lg font-semibold text-gray-800 mb-4">Token使用分布</h4>
|
||||
<div class="relative" style="height: 300px;">
|
||||
<canvas ref="modelUsageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细数据表格 -->
|
||||
<div class="card p-6">
|
||||
<h4 class="text-lg font-semibold text-gray-800 mb-4">详细统计数据</h4>
|
||||
<div v-if="dashboardModelStats.length === 0" class="text-center py-8">
|
||||
<p class="text-gray-500">暂无模型使用数据</p>
|
||||
</div>
|
||||
<div v-else class="overflow-auto max-h-[300px]">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">模型</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">请求数</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">总Token</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">费用</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-sm text-gray-900">{{ stat.model }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.requests) }}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.allTokens) }}</td>
|
||||
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">{{ stat.formatted ? stat.formatted.total : '$0.000000' }}</td>
|
||||
<td class="px-4 py-2 text-sm font-medium text-right">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token使用趋势图 -->
|
||||
<div class="mb-8">
|
||||
<div class="card p-6">
|
||||
<div style="height: 300px;">
|
||||
<canvas ref="usageTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys 使用趋势图 -->
|
||||
<div class="mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
|
||||
<!-- 维度切换按钮 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
apiKeysTrendMetric === 'requests'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-exchange-alt mr-1"></i>请求次数
|
||||
</button>
|
||||
<button
|
||||
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
apiKeysTrendMetric === 'tokens'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-coins mr-1"></i>Token 数量
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
||||
</span>
|
||||
<span v-else>
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 350px;">
|
||||
<canvas ref="apiKeysUsageTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const {
|
||||
dashboardData,
|
||||
costsData,
|
||||
dashboardModelStats,
|
||||
trendData,
|
||||
apiKeysTrendData,
|
||||
formattedUptime,
|
||||
dateFilter,
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
defaultTime
|
||||
} = storeToRefs(dashboardStore)
|
||||
|
||||
const {
|
||||
loadDashboardData,
|
||||
loadUsageTrend,
|
||||
loadModelStats,
|
||||
loadApiKeysTrend,
|
||||
setDateFilterPreset,
|
||||
onCustomDateRangeChange,
|
||||
setTrendGranularity,
|
||||
refreshChartsData,
|
||||
disabledDate
|
||||
} = dashboardStore
|
||||
|
||||
// Chart 实例
|
||||
const modelUsageChart = ref(null)
|
||||
const usageTrendChart = ref(null)
|
||||
const apiKeysUsageTrendChart = ref(null)
|
||||
let modelUsageChartInstance = null
|
||||
let usageTrendChartInstance = null
|
||||
let apiKeysUsageTrendChartInstance = null
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
function calculatePercentage(value, stats) {
|
||||
if (!stats || stats.length === 0) return 0
|
||||
const total = stats.reduce((sum, stat) => sum + stat.allTokens, 0)
|
||||
if (total === 0) return 0
|
||||
return ((value / total) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
// 创建模型使用饼图
|
||||
function createModelUsageChart() {
|
||||
if (!modelUsageChart.value) return
|
||||
|
||||
if (modelUsageChartInstance) {
|
||||
modelUsageChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = dashboardModelStats.value || []
|
||||
const chartData = {
|
||||
labels: data.map(d => d.model),
|
||||
datasets: [{
|
||||
data: data.map(d => d.allTokens),
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||
],
|
||||
borderWidth: 0
|
||||
}]
|
||||
}
|
||||
|
||||
modelUsageChartInstance = new Chart(modelUsageChart.value, {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 15,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label || ''
|
||||
const value = formatNumber(context.parsed)
|
||||
const percentage = calculatePercentage(context.parsed, data)
|
||||
return `${label}: ${value} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建使用趋势图
|
||||
function createUsageTrendChart() {
|
||||
if (!usageTrendChart.value) return
|
||||
|
||||
if (usageTrendChartInstance) {
|
||||
usageTrendChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = trendData.value || []
|
||||
|
||||
// 准备多维度数据
|
||||
const inputData = data.map(d => d.inputTokens || 0)
|
||||
const outputData = data.map(d => d.outputTokens || 0)
|
||||
const cacheCreateData = data.map(d => d.cacheCreateTokens || 0)
|
||||
const cacheReadData = data.map(d => d.cacheReadTokens || 0)
|
||||
const requestsData = data.map(d => d.requests || 0)
|
||||
const costData = data.map(d => d.cost || 0)
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: '输入Token',
|
||||
data: inputData,
|
||||
borderColor: 'rgb(102, 126, 234)',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '输出Token',
|
||||
data: outputData,
|
||||
borderColor: 'rgb(240, 147, 251)',
|
||||
backgroundColor: 'rgba(240, 147, 251, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '缓存创建Token',
|
||||
data: cacheCreateData,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '缓存读取Token',
|
||||
data: cacheReadData,
|
||||
borderColor: 'rgb(147, 51, 234)',
|
||||
backgroundColor: 'rgba(147, 51, 234, 0.1)',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '费用 (USD)',
|
||||
data: costData,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y2'
|
||||
},
|
||||
{
|
||||
label: '请求数',
|
||||
data: requestsData,
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
usageTrendChartInstance = new Chart(usageTrendChart.value, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token使用趋势',
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || ''
|
||||
let value = context.parsed.y
|
||||
|
||||
if (label === '费用 (USD)') {
|
||||
// 格式化费用显示
|
||||
if (value < 0.01) {
|
||||
return label + ': $' + value.toFixed(6)
|
||||
} else {
|
||||
return label + ': $' + value.toFixed(4)
|
||||
}
|
||||
} else if (label === '请求数') {
|
||||
return label + ': ' + value.toLocaleString() + ' 次'
|
||||
} else {
|
||||
return label + ': ' + value.toLocaleString() + ' tokens'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity.value === 'hour' ? '时间' : '日期'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token数量'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return formatNumber(value)
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false, // 隐藏费用轴,在tooltip中显示
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建API Keys使用趋势图
|
||||
function createApiKeysUsageTrendChart() {
|
||||
if (!apiKeysUsageTrendChart.value) return
|
||||
|
||||
if (apiKeysUsageTrendChartInstance) {
|
||||
apiKeysUsageTrendChartInstance.destroy()
|
||||
}
|
||||
|
||||
const data = apiKeysTrendData.value.data || []
|
||||
const metric = apiKeysTrendMetric.value
|
||||
|
||||
// 颜色数组
|
||||
const colors = [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||
]
|
||||
|
||||
// 准备数据集
|
||||
const datasets = apiKeysTrendData.value.topApiKeys?.map((apiKeyId, index) => {
|
||||
const data = apiKeysTrendData.value.data.map(item => {
|
||||
if (!item.apiKeys || !item.apiKeys[apiKeyId]) return 0
|
||||
return metric === 'tokens'
|
||||
? item.apiKeys[apiKeyId].tokens
|
||||
: item.apiKeys[apiKeyId].requests || 0
|
||||
})
|
||||
|
||||
// 获取API Key名称
|
||||
const apiKeyName = apiKeysTrendData.value.data.find(item =>
|
||||
item.apiKeys && item.apiKeys[apiKeyId]
|
||||
)?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`
|
||||
|
||||
return {
|
||||
label: apiKeyName,
|
||||
data: data,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length] + '20',
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}
|
||||
}) || []
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: datasets
|
||||
}
|
||||
|
||||
apiKeysUsageTrendChartInstance = new Chart(apiKeysUsageTrendChart.value, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.parsed.y
|
||||
const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次'
|
||||
return label + ': ' + value.toLocaleString() + unit
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity.value === 'hour' ? '时间' : '日期'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return formatNumber(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新API Keys使用趋势图
|
||||
async function updateApiKeysUsageTrendChart() {
|
||||
await loadApiKeysTrend(apiKeysTrendMetric.value)
|
||||
await nextTick()
|
||||
createApiKeysUsageTrendChart()
|
||||
}
|
||||
|
||||
// 监听数据变化更新图表
|
||||
watch(dashboardModelStats, () => {
|
||||
nextTick(() => createModelUsageChart())
|
||||
})
|
||||
|
||||
watch(trendData, () => {
|
||||
nextTick(() => createUsageTrendChart())
|
||||
})
|
||||
|
||||
watch(apiKeysTrendData, () => {
|
||||
nextTick(() => createApiKeysUsageTrendChart())
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 加载所有数据
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
refreshChartsData() // 使用refreshChartsData来确保根据当前筛选条件加载数据
|
||||
])
|
||||
|
||||
// 创建图表
|
||||
await nextTick()
|
||||
createModelUsageChart()
|
||||
createUsageTrendChart()
|
||||
createApiKeysUsageTrendChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义日期选择器样式 */
|
||||
.custom-date-picker :deep(.el-input__inner) {
|
||||
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.custom-date-picker :deep(.el-range-separator) {
|
||||
@apply text-gray-500;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.custom-date-picker :deep(.el-range-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
90
web/admin-spa/src/views/LoginView.vue
Normal file
90
web/admin-spa/src/views/LoginView.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-6">
|
||||
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
|
||||
<div class="text-center mb-8">
|
||||
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
|
||||
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
|
||||
<template v-if="!oemLoading">
|
||||
<img v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
alt="Logo"
|
||||
class="w-12 h-12 object-contain"
|
||||
@error="(e) => e.target.style.display = 'none'">
|
||||
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
|
||||
</template>
|
||||
<div v-else class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
<template v-if="!oemLoading && authStore.oemSettings.siteName">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ authStore.oemSettings.siteName }}</h1>
|
||||
</template>
|
||||
<div v-else-if="oemLoading" class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"></div>
|
||||
<p class="text-gray-600 text-lg">管理后台</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-3">用户名</label>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-3">密码</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
required
|
||||
class="form-input w-full"
|
||||
placeholder="请输入密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.loginLoading"
|
||||
class="btn btn-primary w-full py-4 px-6 text-lg font-semibold"
|
||||
>
|
||||
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2"></i>
|
||||
<div v-if="authStore.loginLoading" class="loading-spinner mr-2"></div>
|
||||
{{ authStore.loginLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="authStore.loginError" class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>{{ authStore.loginError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 加载OEM设置
|
||||
authStore.loadOemSettings()
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
await authStore.login(loginForm.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式已经在全局样式中定义 */
|
||||
</style>
|
||||
279
web/admin-spa/src/views/SettingsView.vue
Normal file
279
web/admin-spa/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
|
||||
<p class="text-gray-600">自定义网站名称和图标</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">正在加载设置...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="min-w-full">
|
||||
<tbody class="divide-y divide-gray-200/50">
|
||||
<!-- 网站名称 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-font text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站名称</div>
|
||||
<div class="text-xs text-gray-500">品牌标识</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<input
|
||||
v-model="oemSettings.siteName"
|
||||
type="text"
|
||||
class="form-input w-full max-w-md"
|
||||
placeholder="Claude Relay Service"
|
||||
maxlength="100"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 网站图标 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-image text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站图标</div>
|
||||
<div class="text-xs text-gray-500">Favicon</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
<!-- 图标预览 -->
|
||||
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
alt="图标预览"
|
||||
class="w-8 h-8"
|
||||
@error="handleIconError"
|
||||
>
|
||||
<span class="text-sm text-gray-600">当前图标</span>
|
||||
<button
|
||||
@click="removeIcon"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref="iconFileInput"
|
||||
@change="handleIconUpload"
|
||||
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||
class="hidden"
|
||||
>
|
||||
<button
|
||||
@click="$refs.iconFileInput.click()"
|
||||
class="btn btn-success px-4 py-2"
|
||||
>
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
上传图标
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<tr>
|
||||
<td class="px-6 py-6" colspan="2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="saveOemSettings"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': saving }"
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2"></i>
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="resetOemSettings"
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
重置为默认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
// 使用settings store
|
||||
const settingsStore = useSettingsStore()
|
||||
const { loading, saving, oemSettings } = storeToRefs(settingsStore)
|
||||
|
||||
// 组件refs
|
||||
const iconFileInput = ref()
|
||||
|
||||
// 页面加载时获取设置
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await settingsStore.loadOemSettings()
|
||||
} catch (error) {
|
||||
showToast('加载设置失败', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// 保存OEM设置
|
||||
const saveOemSettings = async () => {
|
||||
try {
|
||||
const settings = {
|
||||
siteName: oemSettings.value.siteName,
|
||||
siteIcon: oemSettings.value.siteIcon,
|
||||
siteIconData: oemSettings.value.siteIconData
|
||||
}
|
||||
const result = await settingsStore.saveOemSettings(settings)
|
||||
if (result && result.success) {
|
||||
showToast('OEM设置保存成功', 'success')
|
||||
} else {
|
||||
showToast(result?.message || '保存失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('保存OEM设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置OEM设置
|
||||
const resetOemSettings = async () => {
|
||||
if (!confirm('确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。')) return
|
||||
|
||||
try {
|
||||
const result = await settingsStore.resetOemSettings()
|
||||
if (result && result.success) {
|
||||
showToast('已重置为默认设置', 'success')
|
||||
} else {
|
||||
showToast('重置失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('重置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理图标上传
|
||||
const handleIconUpload = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件
|
||||
const validation = settingsStore.validateIconFile(file)
|
||||
if (!validation.isValid) {
|
||||
validation.errors.forEach(error => showToast(error, 'error'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 转换为Base64
|
||||
const base64Data = await settingsStore.fileToBase64(file)
|
||||
oemSettings.value.siteIconData = base64Data
|
||||
} catch (error) {
|
||||
showToast('文件读取失败', 'error')
|
||||
}
|
||||
|
||||
// 清除input的值,允许重复选择同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// 删除图标
|
||||
const removeIcon = () => {
|
||||
oemSettings.value.siteIcon = ''
|
||||
oemSettings.value.siteIconData = ''
|
||||
}
|
||||
|
||||
// 处理图标加载错误
|
||||
const handleIconError = () => {
|
||||
console.warn('Icon failed to load')
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = settingsStore.formatDateTime
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-5 h-5 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin;
|
||||
}
|
||||
</style>
|
||||
828
web/admin-spa/src/views/TutorialView.vue
Normal file
828
web/admin-spa/src/views/TutorialView.vue
Normal file
@@ -0,0 +1,828 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<i class="fas fa-graduation-cap text-blue-600 mr-3"></i>
|
||||
Claude Code 使用教程
|
||||
</h3>
|
||||
<p class="text-gray-600 text-lg">跟着这个教程,你可以轻松在自己的电脑上安装并使用 Claude Code。</p>
|
||||
</div>
|
||||
|
||||
<!-- 系统选择标签 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-wrap gap-2 p-2 bg-gray-100 rounded-xl">
|
||||
<button
|
||||
v-for="system in tutorialSystems"
|
||||
:key="system.key"
|
||||
@click="activeTutorialSystem = system.key"
|
||||
:class="['flex-1 py-3 px-6 text-sm font-semibold rounded-lg transition-all duration-300 flex items-center justify-center gap-2',
|
||||
activeTutorialSystem === system.key
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900']"
|
||||
>
|
||||
<i :class="system.icon"></i>
|
||||
{{ system.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Windows 教程 -->
|
||||
<div v-if="activeTutorialSystem === 'windows'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-windows text-blue-600 mr-2"></i>
|
||||
Windows 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:官网下载(推荐)</p>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
|
||||
<li>打开浏览器访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
|
||||
<li>点击 "LTS" 版本进行下载(推荐长期支持版本)</li>
|
||||
<li>下载完成后双击 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.msi</code> 文件</li>
|
||||
<li>按照安装向导完成安装,保持默认设置即可</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:使用包管理器</p>
|
||||
<p class="text-gray-600 mb-2">如果你安装了 Chocolatey 或 Scoop,可以使用命令行安装:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 使用 Chocolatey</div>
|
||||
<div class="text-gray-300">choco install nodejs</div>
|
||||
<div class="mt-3 mb-2"># 或使用 Scoop</div>
|
||||
<div class="text-gray-300">scoop install nodejs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-blue-800 mb-2">Windows 注意事项</h6>
|
||||
<ul class="text-blue-700 text-sm space-y-1">
|
||||
<li>• 建议使用 PowerShell 而不是 CMD</li>
|
||||
<li>• 如果遇到权限问题,尝试以管理员身份运行</li>
|
||||
<li>• 某些杀毒软件可能会误报,需要添加白名单</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开 PowerShell 或 CMD,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Git Bash -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Git Bash
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Windows 环境下需要使用 Git Bash 安装Claude code。安装完成后,环境变量设置和使用 Claude Code 仍然在普通的 PowerShell 或 CMD 中进行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl p-6 border border-green-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-git-alt text-green-600 mr-2"></i>
|
||||
下载并安装 Git for Windows
|
||||
</h5>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4 mb-4">
|
||||
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://git-scm.com/downloads/win</code></li>
|
||||
<li>点击 "Download for Windows" 下载安装包</li>
|
||||
<li>运行下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.exe</code> 安装文件</li>
|
||||
<li>在安装过程中保持默认设置,直接点击 "Next" 完成安装</li>
|
||||
</ol>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">安装完成后</h6>
|
||||
<ul class="text-green-700 text-sm space-y-1">
|
||||
<li>• 在任意文件夹右键可以看到 "Git Bash Here" 选项</li>
|
||||
<li>• 也可以从开始菜单启动 "Git Bash"</li>
|
||||
<li>• 只需要在 Git Bash 中运行 npm install 命令</li>
|
||||
<li>• 后续的环境变量设置和使用都在 PowerShell/CMD 中</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Git Bash 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">打开 Git Bash,输入以下命令验证:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">git --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示 Git 版本号,说明安装成功!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开 Git Bash(重要:不要使用 PowerShell),运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 在 Git Bash 中全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code。</p>
|
||||
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||||
<h6 class="font-medium text-yellow-800 mb-2">重要提醒</h6>
|
||||
<ul class="text-yellow-700 text-sm space-y-1">
|
||||
<li>• 必须在 Git Bash 中运行,不要在 PowerShell 中运行</li>
|
||||
<li>• 如果遇到权限问题,可以尝试在 Git Bash 中使用 sudo 命令</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:PowerShell 临时设置(推荐)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在 PowerShell 中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">$env:ANTHROPIC_BASE_URL = "{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">$env:ANTHROPIC_AUTH_TOKEN = "你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:系统环境变量(永久设置)</h6>
|
||||
<ol class="text-gray-600 text-sm space-y-1 list-decimal list-inside">
|
||||
<li>右键"此电脑" → "属性" → "高级系统设置"</li>
|
||||
<li>点击"环境变量"按钮</li>
|
||||
<li>在"用户变量"或"系统变量"中点击"新建"</li>
|
||||
<li>添加以下两个变量:</li>
|
||||
</ol>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||
<strong>变量名:</strong> ANTHROPIC_BASE_URL<br>
|
||||
<strong>变量值:</strong> <span class="font-mono">{{ currentBaseUrl }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||
<strong>变量名:</strong> ANTHROPIC_AUTH_TOKEN<br>
|
||||
<strong>变量值:</strong> <span class="font-mono">你的API密钥</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证环境变量设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
|
||||
<h6 class="font-medium text-blue-800 mb-2">验证环境变量设置</h6>
|
||||
<p class="text-blue-700 text-sm mb-3">设置完环境变量后,可以通过以下命令验证是否设置成功:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在 PowerShell 中验证:</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
|
||||
<div class="text-gray-300">echo $env:ANTHROPIC_BASE_URL</div>
|
||||
<div class="text-gray-300">echo $env:ANTHROPIC_AUTH_TOKEN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在 CMD 中验证:</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
|
||||
<div class="text-gray-300">echo %ANTHROPIC_BASE_URL%</div>
|
||||
<div class="text-gray-300">echo %ANTHROPIC_AUTH_TOKEN%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<p class="text-blue-700 text-sm">
|
||||
<strong>预期输出示例:</strong>
|
||||
</p>
|
||||
<div class="bg-gray-100 p-2 rounded text-sm font-mono">
|
||||
<div>{{ currentBaseUrl }}</div>
|
||||
<div>cr_xxxxxxxxxxxxxxxxxx</div>
|
||||
</div>
|
||||
<p class="text-blue-700 text-xs">
|
||||
💡 如果输出为空或显示变量名本身,说明环境变量设置失败,请重新设置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第五步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">5</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd C:\path\to\your\project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Windows 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
Windows 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示 "permission denied" 错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">这通常是权限问题,尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>以管理员身份运行 PowerShell</li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix %APPDATA%\npm</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
PowerShell 执行策略错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">如果遇到执行策略限制,运行:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量设置后不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">设置永久环境变量后需要:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>重新启动 PowerShell 或 CMD</li>
|
||||
<li>或者注销并重新登录 Windows</li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $env:ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS 教程 -->
|
||||
<div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-gray-50 to-slate-50 rounded-xl p-6 border border-gray-200 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-apple text-gray-700 mr-2"></i>
|
||||
macOS 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:使用 Homebrew(推荐)</p>
|
||||
<p class="text-gray-600 mb-2">如果你已经安装了 Homebrew,使用它安装 Node.js 会更方便:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 更新 Homebrew</div>
|
||||
<div class="text-gray-300">brew update</div>
|
||||
<div class="mt-3 mb-2"># 安装 Node.js</div>
|
||||
<div class="text-gray-300">brew install node</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:官网下载</p>
|
||||
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
|
||||
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
|
||||
<li>下载适合 macOS 的 LTS 版本</li>
|
||||
<li>打开下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.pkg</code> 文件</li>
|
||||
<li>按照安装程序指引完成安装</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-gray-800 mb-2">macOS 注意事项</h6>
|
||||
<ul class="text-gray-700 text-sm space-y-1">
|
||||
<li>• 如果遇到权限问题,可能需要使用 <code class="bg-gray-200 px-1 rounded">sudo</code></li>
|
||||
<li>• 首次运行可能需要在系统偏好设置中允许</li>
|
||||
<li>• 建议使用 Terminal 或 iTerm2</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开 Terminal,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开 Terminal,运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题,可以使用 sudo:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:临时设置(当前会话)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在 Terminal 中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:永久设置</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件(根据你使用的 shell):</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
|
||||
<div class="mb-2"># 对于 zsh (默认)</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">source ~/.zshrc</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 对于 bash</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bash_profile</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bash_profile</div>
|
||||
<div class="text-gray-300">source ~/.bash_profile</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd /path/to/your/project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
macOS 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示权限错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>使用 sudo 安装:<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
macOS 安全设置阻止运行
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">如果系统阻止运行 Claude Code:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>打开"系统偏好设置" → "安全性与隐私"</li>
|
||||
<li>点击"仍要打开"或"允许"</li>
|
||||
<li>或者在 Terminal 中运行:<code class="bg-gray-200 px-1 rounded">sudo spctl --master-disable</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">检查以下几点:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>确认修改了正确的配置文件(.zshrc 或 .bash_profile)</li>
|
||||
<li>重新启动 Terminal</li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linux 教程 -->
|
||||
<div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content">
|
||||
<!-- 第一步:安装 Node.js -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
|
||||
安装 Node.js 环境
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行。</p>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-red-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fab fa-ubuntu text-orange-600 mr-2"></i>
|
||||
Linux 安装方法
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法一:使用官方仓库(推荐)</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># 添加 NodeSource 仓库</div>
|
||||
<div class="text-gray-300">curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -</div>
|
||||
<div class="mt-3 mb-2"># 安装 Node.js</div>
|
||||
<div class="text-gray-300">sudo apt-get install -y nodejs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700 mb-3">方法二:使用系统包管理器</p>
|
||||
<p class="text-gray-600 mb-2">虽然版本可能不是最新的,但对于基本使用已经足够:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="mb-2"># Ubuntu/Debian</div>
|
||||
<div class="text-gray-300">sudo apt update</div>
|
||||
<div class="text-gray-300">sudo apt install nodejs npm</div>
|
||||
<div class="mt-3 mb-2"># CentOS/RHEL/Fedora</div>
|
||||
<div class="text-gray-300">sudo dnf install nodejs npm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-orange-800 mb-2">Linux 注意事项</h6>
|
||||
<ul class="text-orange-700 text-sm space-y-1">
|
||||
<li>• 某些发行版可能需要安装额外的依赖</li>
|
||||
<li>• 如果遇到权限问题,使用 <code class="bg-orange-200 px-1 rounded">sudo</code></li>
|
||||
<li>• 确保你的用户在 npm 的全局目录有写权限</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,打开终端,输入以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">node --version</div>
|
||||
<div class="text-gray-300">npm --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,说明安装成功了!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:安装 Claude Code -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
|
||||
安装 Claude Code
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-download text-purple-600 mr-2"></i>
|
||||
安装 Claude Code
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">打开终端,运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
|
||||
<div class="mb-2"># 全局安装 Claude Code</div>
|
||||
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题,可以使用 sudo:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证安装 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
|
||||
<p class="text-green-700 text-sm mb-3">安装完成后,输入以下命令检查是否安装成功:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude --version</div>
|
||||
</div>
|
||||
<p class="text-green-700 text-sm mt-2">如果显示版本号,恭喜你!Claude Code 已经成功安装了。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:设置环境变量 -->
|
||||
<div class="mb-10">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
|
||||
设置环境变量
|
||||
</h4>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
|
||||
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<i class="fas fa-cog text-orange-600 mr-2"></i>
|
||||
配置 Claude Code 环境变量
|
||||
</h5>
|
||||
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务,需要设置两个环境变量:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法一:临时设置(当前会话)</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">在终端中运行以下命令:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
|
||||
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
|
||||
</div>
|
||||
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥。</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||
<h6 class="font-medium text-gray-800 mb-2">方法二:永久设置</h6>
|
||||
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
|
||||
<div class="mb-2"># 对于 bash (默认)</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bashrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bashrc</div>
|
||||
<div class="text-gray-300">source ~/.bashrc</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 对于 zsh</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
|
||||
<div class="text-gray-300">source ~/.zshrc</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:开始使用 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
|
||||
开始使用 Claude Code
|
||||
</h4>
|
||||
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
|
||||
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code 了!</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># 进入你的项目目录</div>
|
||||
<div class="text-gray-300">cd /path/to/your/project</div>
|
||||
<div class="mt-2 mb-2"># 启动 Claude Code</div>
|
||||
<div class="text-gray-300">claude</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linux 故障排除 -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-wrench text-red-600 mr-3"></i>
|
||||
Linux 常见问题解决
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
安装时提示权限错误
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">尝试以下解决方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>使用 sudo 安装:<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
|
||||
<li>或者配置 npm 使用用户目录:<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
|
||||
<li>然后添加到 PATH:<code class="bg-gray-200 px-1 rounded">export PATH=~/.npm-global/bin:$PATH</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
缺少依赖库
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">某些 Linux 发行版需要安装额外依赖:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
|
||||
<div class="mb-2"># Ubuntu/Debian</div>
|
||||
<div class="text-gray-300">sudo apt install build-essential</div>
|
||||
<div class="mt-2 mb-2"># CentOS/RHEL</div>
|
||||
<div class="text-gray-300">sudo dnf groupinstall "Development Tools"</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="bg-gray-50 rounded-lg border border-gray-200">
|
||||
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
|
||||
环境变量不生效
|
||||
</summary>
|
||||
<div class="px-4 pb-4 text-gray-600">
|
||||
<p class="mb-2">检查以下几点:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>确认修改了正确的配置文件(.bashrc 或 .zshrc)</li>
|
||||
<li>重新启动终端或运行 <code class="bg-gray-200 px-1 rounded">source ~/.bashrc</code></li>
|
||||
<li>验证设置:<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结尾 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl p-6 text-center">
|
||||
<h5 class="text-xl font-semibold mb-2">🎉 恭喜你!</h5>
|
||||
<p class="text-blue-100 mb-4">你已经成功安装并配置了 Claude Code,现在可以开始享受 AI 编程助手带来的便利了。</p>
|
||||
<p class="text-sm text-blue-200">如果在使用过程中遇到任何问题,可以查看官方文档或社区讨论获取帮助。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 当前系统选择
|
||||
const activeTutorialSystem = ref('windows')
|
||||
|
||||
// 系统列表
|
||||
const tutorialSystems = [
|
||||
{ key: 'windows', name: 'Windows', icon: 'fab fa-windows' },
|
||||
{ key: 'macos', name: 'macOS', icon: 'fab fa-apple' },
|
||||
{ key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' },
|
||||
]
|
||||
|
||||
// 当前域名
|
||||
const currentDomain = computed(() => {
|
||||
return window.location.origin
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.tutorial-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.tutorial-content h4 {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.tutorial-content .bg-gradient-to-r {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-content .bg-gradient-to-r:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user