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

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

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

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

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

🚀 生成方式:使用 Claude Code

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

View File

@@ -0,0 +1,923 @@
<template>
<div class="tab-content">
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
<p class="text-gray-600">管理和监控您的 API 密钥</p>
</div>
<div class="flex items-center gap-3">
<!-- Token统计时间范围选择 -->
<select
v-model="apiKeyStatsTimeRange"
@change="loadApiKeys()"
class="form-input px-3 py-2 text-sm"
>
<option value="today">今日</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
<option value="all">全部时间</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
>
<i class="fas fa-plus"></i>创建新 Key
</button>
</div>
</div>
<div v-if="apiKeysLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载 API Keys...</p>
</div>
<div v-else-if="apiKeys.length === 0" class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-key text-gray-400 text-xl"></i>
</div>
<p class="text-gray-500 text-lg">暂无 API Keys</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮创建您的第一个 API Key</p>
</div>
<div v-else class="table-container">
<table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
名称
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
状态
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
使用统计
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
(费用
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
</span>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
创建时间
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
过期时间
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<template v-for="key in sortedApiKeys" :key="key.id">
<!-- API Key 主行 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-key text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
<div class="text-xs text-gray-500">{{ key.id }}</div>
<div class="text-xs text-gray-500 mt-1">
<span v-if="key.claudeAccountId">
<i class="fas fa-link mr-1"></i>
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1"></i>
使用共享池
</span>
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
{{ (key.apiKey || '').substring(0, 20) }}...
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
key.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
{{ key.isActive ? '活跃' : '禁用' }}
</span>
</td>
<td class="px-6 py-4">
<div class="space-y-1">
<!-- 请求统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">请求数:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
</div>
<!-- Token统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">Token:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span>
</div>
<!-- 费用统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">费用:</span>
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
</div>
<!-- 每日费用限制 -->
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
<span class="text-gray-600">今日费用:</span>
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<!-- 并发限制 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">并发限制:</span>
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
</div>
<!-- 当前并发数 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">当前并发:</span>
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
{{ key.currentConcurrency || 0 }}
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
</span>
</div>
<!-- 时间窗口限流 -->
<div v-if="key.rateLimitWindow > 0" class="flex justify-between text-sm">
<span class="text-gray-600">时间窗口:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
</div>
<!-- 请求次数限制 -->
<div v-if="key.rateLimitRequests > 0" class="flex justify-between text-sm">
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} /窗口</span>
</div>
<!-- 输入/输出Token -->
<div class="flex justify-between text-xs text-gray-500">
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div>
<!-- 缓存Token细节 -->
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
<!-- RPM/TPM -->
<div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
</div>
<!-- 今日统计 -->
<div class="pt-1 border-t border-gray-100">
<div class="flex justify-between text-xs text-green-600">
<span>今日: {{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }}</span>
<span>{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}T</span>
</div>
</div>
<!-- 模型分布按钮 -->
<div class="pt-2">
<button @click="toggleApiKeyModelStats(key.id)" v-if="key && key.id" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']"></i>
模型使用分布
</button>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ new Date(key.createdAt).toLocaleDateString() }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="key.expiresAt">
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
<i class="fas fa-exclamation-circle mr-1"></i>
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
<i class="fas fa-clock mr-1"></i>
{{ formatExpireDate(key.expiresAt) }}
</div>
<div v-else class="text-gray-600">
{{ formatExpireDate(key.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400">
<i class="fas fa-infinity mr-1"></i>
永不过期
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-2">
<button
@click="copyApiStatsLink(key)"
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
title="复制统计页面链接"
>
<i class="fas fa-chart-bar mr-1"></i>统计
</button>
<button
@click="openEditApiKeyModal(key)"
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-edit mr-1"></i>编辑
</button>
<button
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
@click="openRenewApiKeyModal(key)"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-clock mr-1"></i>续期
</button>
<button
@click="deleteApiKey(key.id)"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-trash mr-1"></i>删除
</button>
</div>
</td>
</tr>
<!-- 模型统计展开区域 -->
<tr v-if="key && key.id && expandedApiKeys[key.id]">
<td colspan="7" class="px-6 py-4 bg-gray-50">
<div v-if="!apiKeyModelStats[key.id]" class="text-center py-4">
<div class="loading-spinner mx-auto"></div>
<p class="text-sm text-gray-500 mt-2">加载模型统计...</p>
</div>
<div class="space-y-4">
<!-- 通用的标题和时间筛选器无论是否有数据都显示 -->
<div class="flex items-center justify-between mb-4">
<h5 class="text-sm font-semibold text-gray-700 flex items-center">
<i class="fas fa-chart-pie text-indigo-500 mr-2"></i>
模型使用分布
</h5>
<div class="flex items-center gap-2">
<span v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{{ apiKeyModelStats[key.id].length }} 个模型
</span>
<!-- API Keys日期筛选器 -->
<div class="flex gap-1 items-center">
<!-- 快捷日期选择 -->
<div class="flex gap-1 bg-gray-100 rounded p-1">
<button
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
:key="option.value"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
:class="[
'px-2 py-1 rounded text-xs font-medium transition-colors',
getApiKeyDateFilter(key.id).preset === option.value && getApiKeyDateFilter(key.id).type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
{{ option.label }}
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<el-date-picker
:model-value="getApiKeyDateFilter(key.id).customRange"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disabledDate"
:default-time="defaultTime"
size="small"
style="width: 280px;"
class="api-key-date-picker"
:clearable="true"
:unlink-panels="false"
></el-date-picker>
</div>
</div>
</div>
<!-- 数据展示区域 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0" class="text-center py-8">
<div class="flex items-center justify-center gap-2 mb-3">
<i class="fas fa-chart-line text-gray-400 text-lg"></i>
<p class="text-sm text-gray-500">暂无模型使用数据</p>
<button
@click="resetApiKeyDateFilter(key.id)"
class="text-blue-500 hover:text-blue-700 text-sm ml-2 flex items-center gap-1 transition-colors"
title="重置筛选条件并刷新"
>
<i class="fas fa-sync-alt text-xs"></i>
<span class="text-xs">刷新</span>
</button>
</div>
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p>
</div>
<div v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="stat in apiKeyModelStats[key.id]" :key="stat.model"
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<span class="text-sm font-semibold text-gray-800 block mb-1">{{ stat.model }}</span>
<span class="text-xs text-gray-500 bg-blue-50 px-2 py-1 rounded-full">{{ stat.requests }} 次请求</span>
</div>
</div>
<div class="space-y-2 mb-3">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-coins text-yellow-500 mr-1 text-xs"></i>
总Token:
</span>
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs"></i>
费用:
</span>
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
</div>
<div class="pt-2 mt-2 border-t border-gray-100">
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-down text-green-500 mr-1"></i>
输入:
</span>
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-1"></i>
输出:
</span>
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
</div>
<div v-if="stat.cacheCreateTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<span class="flex items-center">
<i class="fas fa-save mr-1"></i>
缓存创建:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
</div>
<div v-if="stat.cacheReadTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<span class="flex items-center">
<i class="fas fa-download mr-1"></i>
缓存读取:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="w-full bg-gray-200 rounded-full h-2 mt-3">
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }">
</div>
</div>
<div class="text-right mt-1">
<span class="text-xs font-medium text-indigo-600">
{{ calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) }}%
</span>
</div>
</div>
</div>
<!-- 总计统计,仅在有数据时显示 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100">
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-700 flex items-center">
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
总计统计
</span>
<div class="flex gap-4 text-xs">
<span class="text-gray-600">
总请求: <span class="font-semibold text-gray-800">{{ apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) }}</span>
</span>
<span class="text-gray-600">
总Token: <span class="font-semibold text-gray-800">{{ formatNumber(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- 模态框组件 -->
<CreateApiKeyModal
v-if="showCreateApiKeyModal"
:accounts="accounts"
@close="showCreateApiKeyModal = false"
@success="handleCreateSuccess"
/>
<EditApiKeyModal
v-if="showEditApiKeyModal"
:apiKey="editingApiKey"
:accounts="accounts"
@close="showEditApiKeyModal = false"
@success="handleEditSuccess"
/>
<RenewApiKeyModal
v-if="showRenewApiKeyModal"
:apiKey="renewingApiKey"
@close="showRenewApiKeyModal = false"
@success="handleRenewSuccess"
/>
<NewApiKeyModal
v-if="showNewApiKeyModal"
:apiKeyData="newApiKeyData"
@close="showNewApiKeyModal = false"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
// 响应式数据
const clientsStore = useClientsStore()
const apiKeys = ref([])
const apiKeysLoading = ref(false)
const apiKeyStatsTimeRange = ref('today')
const apiKeysSortBy = ref('')
const apiKeysSortOrder = ref('asc')
const expandedApiKeys = ref({})
const apiKeyModelStats = ref({})
const apiKeyDateFilters = ref({})
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
const accounts = ref({ claude: [], gemini: [] })
// 模态框状态
const showCreateApiKeyModal = ref(false)
const showEditApiKeyModal = ref(false)
const showRenewApiKeyModal = ref(false)
const showNewApiKeyModal = ref(false)
const editingApiKey = ref(null)
const renewingApiKey = ref(null)
const newApiKeyData = ref(null)
// 计算排序后的API Keys
const sortedApiKeys = computed(() => {
if (!apiKeysSortBy.value) return apiKeys.value
const sorted = [...apiKeys.value].sort((a, b) => {
let aVal = a[apiKeysSortBy.value]
let bVal = b[apiKeysSortBy.value]
// 处理特殊排序字段
if (apiKeysSortBy.value === 'status') {
aVal = a.isActive ? 1 : 0
bVal = b.isActive ? 1 : 0
} else if (apiKeysSortBy.value === 'cost') {
aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
aVal = aVal ? new Date(aVal).getTime() : 0
bVal = bVal ? new Date(bVal).getTime() : 0
}
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
return 0
})
return sorted
})
// 加载账户列表
const loadAccounts = async () => {
try {
const [claudeData, geminiData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/gemini-accounts')
])
if (claudeData.success) {
accounts.value.claude = claudeData.data || []
}
if (geminiData.success) {
accounts.value.gemini = geminiData.data || []
}
} catch (error) {
console.error('加载账户列表失败:', error)
}
}
// 加载API Keys
const loadApiKeys = async () => {
apiKeysLoading.value = true
try {
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
if (data.success) {
apiKeys.value = data.data || []
}
} catch (error) {
showToast('加载 API Keys 失败', 'error')
} finally {
apiKeysLoading.value = false
}
}
// 排序API Keys
const sortApiKeys = (field) => {
if (apiKeysSortBy.value === field) {
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
apiKeysSortBy.value = field
apiKeysSortOrder.value = 'asc'
}
}
// 格式化数字
const formatNumber = (num) => {
if (!num && num !== 0) return '0'
return num.toLocaleString('zh-CN')
}
// 计算API Key费用
const calculateApiKeyCost = (usage) => {
if (!usage || !usage.total) return '$0.0000'
const cost = usage.total.cost || 0
return `$${cost.toFixed(4)}`
}
// 获取绑定账户名称
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
// 从Claude账户列表中查找
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
if (claudeAccount) {
return claudeAccount.name
}
// 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
if (geminiAccount) {
return geminiAccount.name
}
// 如果找不到返回账户ID的前8位
return `账户-${accountId.substring(0, 8)}`
}
// 检查API Key是否过期
const isApiKeyExpired = (expiresAt) => {
if (!expiresAt) return false
return new Date(expiresAt) < new Date()
}
// 检查API Key是否即将过期
const isApiKeyExpiringSoon = (expiresAt) => {
if (!expiresAt || isApiKeyExpired(expiresAt)) return false
const daysUntilExpiry = (new Date(expiresAt) - new Date()) / (1000 * 60 * 60 * 24)
return daysUntilExpiry <= 7
}
// 格式化过期日期
const formatExpireDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('zh-CN')
}
// 切换模型统计展开状态
const toggleApiKeyModelStats = async (keyId) => {
if (!expandedApiKeys.value[keyId]) {
expandedApiKeys.value[keyId] = true
// 初始化日期筛选器
if (!apiKeyDateFilters.value[keyId]) {
initApiKeyDateFilter(keyId)
}
// 加载模型统计数据
await loadApiKeyModelStats(keyId, true)
} else {
expandedApiKeys.value[keyId] = false
}
}
// 加载 API Key 的模型统计
const loadApiKeyModelStats = async (keyId, forceReload = false) => {
if (!forceReload && apiKeyModelStats.value[keyId] && apiKeyModelStats.value[keyId].length > 0) {
return
}
const filter = getApiKeyDateFilter(keyId)
try {
let url = `/admin/api-keys/${keyId}/model-stats`
const params = new URLSearchParams()
if (filter.customStart && filter.customEnd) {
params.append('startDate', filter.customStart)
params.append('endDate', filter.customEnd)
params.append('period', 'custom')
} else {
const period = filter.preset === 'today' ? 'daily' : 'monthly'
params.append('period', period)
}
url += '?' + params.toString()
const data = await apiClient.get(url)
if (data.success) {
apiKeyModelStats.value[keyId] = data.data || []
}
} catch (error) {
showToast('加载模型统计失败', 'error')
apiKeyModelStats.value[keyId] = []
}
}
// 计算API Key模型使用百分比
const calculateApiKeyModelPercentage = (value, stats) => {
const total = stats.reduce((sum, stat) => sum + (stat.allTokens || 0), 0)
if (total === 0) return 0
return Math.round((value / total) * 100)
}
// 计算单个模型费用
const calculateModelCost = (stat) => {
// 优先使用后端返回的费用数据
if (stat.formatted && stat.formatted.total) {
return stat.formatted.total
}
// 如果没有 formatted 数据,尝试使用 cost 字段
if (stat.cost !== undefined) {
return `$${stat.cost.toFixed(6)}`
}
// 默认返回
return '$0.000000'
}
// 初始化API Key的日期筛选器
const initApiKeyDateFilter = (keyId) => {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6) // 7天前
apiKeyDateFilters.value[keyId] = {
type: 'preset',
preset: '7days',
customStart: startDate.toISOString().split('T')[0],
customEnd: today.toISOString().split('T')[0],
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
}
}
// 获取API Key的日期筛选器状态
const getApiKeyDateFilter = (keyId) => {
if (!apiKeyDateFilters.value[keyId]) {
initApiKeyDateFilter(keyId)
}
return apiKeyDateFilters.value[keyId]
}
// 设置 API Key 日期预设
const setApiKeyDateFilterPreset = (preset, keyId) => {
const filter = getApiKeyDateFilter(keyId)
filter.type = 'preset'
filter.preset = preset
const option = filter.presetOptions.find(opt => opt.value === preset)
if (option) {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - (option.days - 1))
filter.customStart = startDate.toISOString().split('T')[0]
filter.customEnd = today.toISOString().split('T')[0]
const formatDate = (date) => {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' 00:00:00'
}
filter.customRange = [
formatDate(startDate),
formatDate(today)
]
}
loadApiKeyModelStats(keyId, true)
}
// API Key 自定义日期范围变化
const onApiKeyCustomDateRangeChange = (keyId, value) => {
const filter = getApiKeyDateFilter(keyId)
if (value && value.length === 2) {
filter.type = 'custom'
filter.preset = ''
filter.customRange = value
filter.customStart = value[0].split(' ')[0]
filter.customEnd = value[1].split(' ')[0]
loadApiKeyModelStats(keyId, true)
} else if (value === null) {
// 清空时恢复默认7天
setApiKeyDateFilterPreset('7days', keyId)
}
}
// 禁用未来日期
const disabledDate = (date) => {
return date > new Date()
}
// 重置API Key日期筛选器
const resetApiKeyDateFilter = (keyId) => {
const filter = getApiKeyDateFilter(keyId)
// 重置为默认的7天
filter.type = 'preset'
filter.preset = '7days'
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6)
filter.customStart = startDate.toISOString().split('T')[0]
filter.customEnd = today.toISOString().split('T')[0]
filter.customRange = null
// 重新加载数据
loadApiKeyModelStats(keyId, true)
showToast('已重置筛选条件并刷新数据', 'info')
}
// 打开创建模态框
const openCreateApiKeyModal = () => {
showCreateApiKeyModal.value = true
}
// 打开编辑模态框
const openEditApiKeyModal = (apiKey) => {
editingApiKey.value = apiKey
showEditApiKeyModal.value = true
}
// 打开续期模态框
const openRenewApiKeyModal = (apiKey) => {
renewingApiKey.value = apiKey
showRenewApiKeyModal.value = true
}
// 处理创建成功
const handleCreateSuccess = (data) => {
showCreateApiKeyModal.value = false
newApiKeyData.value = data
showNewApiKeyModal.value = true
loadApiKeys()
}
// 处理编辑成功
const handleEditSuccess = () => {
showEditApiKeyModal.value = false
showToast('API Key 更新成功', 'success')
loadApiKeys()
}
// 处理续期成功
const handleRenewSuccess = () => {
showRenewApiKeyModal.value = false
showToast('API Key 续期成功', 'success')
loadApiKeys()
}
// 删除API Key
const deleteApiKey = async (keyId) => {
if (!confirm('确定要删除这个 API Key 吗?此操作不可恢复。')) return
try {
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
if (data.success) {
showToast('API Key 已删除', 'success')
loadApiKeys()
} else {
showToast(data.message || '删除失败', 'error')
}
} catch (error) {
showToast('删除失败', 'error')
}
}
// 复制API统计页面链接
const copyApiStatsLink = (apiKey) => {
// 构建统计页面的完整URL
const baseUrl = window.location.origin
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
// 使用传统的textarea方法复制到剪贴板
const textarea = document.createElement('textarea')
textarea.value = statsUrl
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, 99999) // 兼容移动端
try {
const successful = document.execCommand('copy')
if (successful) {
showToast(`已复制统计页面链接`, 'success')
} else {
showToast('复制失败,请手动复制', 'error')
console.log('统计页面链接:', statsUrl)
}
} catch (err) {
showToast('复制失败,请手动复制', 'error')
console.error('复制错误:', err)
console.log('统计页面链接:', statsUrl)
} finally {
document.body.removeChild(textarea)
}
}
onMounted(async () => {
// 并行加载所有需要的数据
await Promise.all([
clientsStore.loadSupportedClients(),
loadAccounts(),
loadApiKeys()
])
})
</script>
<style scoped>
.tab-content {
min-height: calc(100vh - 300px);
}
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.api-key-date-picker :deep(.el-input__inner) {
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
}
.api-key-date-picker :deep(.el-range-separator) {
@apply text-gray-500;
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<div class="min-h-screen gradient-bg p-6">
<!-- 顶部导航 -->
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<LogoTitle
:loading="oemLoading"
:title="oemSettings.siteName"
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
/>
<div class="flex items-center gap-3">
<router-link to="/dashboard" class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2">
<i class="fas fa-cog text-sm"></i>
<span class="text-sm font-medium">管理后台</span>
</router-link>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="mb-8">
<div class="flex justify-center">
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20">
<button
@click="currentTab = 'stats'"
:class="[
'tab-pill-button',
currentTab === 'stats' ? 'active' : ''
]"
>
<i class="fas fa-chart-line mr-2"></i>
<span>统计查询</span>
</button>
<button
@click="currentTab = 'tutorial'"
:class="[
'tab-pill-button',
currentTab === 'tutorial' ? 'active' : ''
]"
>
<i class="fas fa-graduation-cap mr-2"></i>
<span>使用教程</span>
</button>
</div>
</div>
</div>
<!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content">
<!-- API Key 输入区域 -->
<ApiKeyInput />
<!-- 错误提示 -->
<div v-if="error" class="mb-8">
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>
{{ error }}
</div>
</div>
<!-- 统计数据展示区域 -->
<div v-if="statsData" class="fade-in">
<div class="glass-strong rounded-3xl p-6 shadow-xl">
<!-- 时间范围选择器 -->
<div class="mb-6 pb-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<i class="fas fa-clock text-blue-500 text-lg"></i>
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
</div>
<div class="flex gap-2">
<button
@click="switchPeriod('daily')"
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
>
<i class="fas fa-calendar-day"></i>
今日
</button>
<button
@click="switchPeriod('monthly')"
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
>
<i class="fas fa-calendar-alt"></i>
本月
</button>
</div>
</div>
</div>
<!-- 基本信息和统计概览 -->
<StatsOverview />
<!-- Token 分布和限制配置 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<TokenDistribution />
<LimitConfig />
</div>
<!-- 模型使用统计 -->
<ModelUsageStats />
</div>
</div>
</div>
<!-- 教程内容 -->
<div v-if="currentTab === 'tutorial'" class="tab-content">
<div class="glass-strong rounded-3xl shadow-xl">
<TutorialView />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
import StatsOverview from '@/components/apistats/StatsOverview.vue'
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
import LimitConfig from '@/components/apistats/LimitConfig.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialView from './TutorialView.vue'
const route = useRoute()
const apiStatsStore = useApiStatsStore()
// 当前标签页
const currentTab = ref('stats')
const {
apiKey,
apiId,
loading,
modelStatsLoading,
oemLoading,
error,
statsPeriod,
statsData,
oemSettings
} = storeToRefs(apiStatsStore)
const {
queryStats,
switchPeriod,
loadStatsWithApiId,
loadOemSettings,
reset
} = apiStatsStore
// 处理键盘快捷键
const handleKeyDown = (event) => {
// Ctrl/Cmd + Enter 查询
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
if (!loading.value && apiKey.value.trim()) {
queryStats()
}
event.preventDefault()
}
// ESC 清除数据
if (event.key === 'Escape') {
reset()
}
}
// 初始化
onMounted(() => {
console.log('API Stats Page loaded')
// 加载 OEM 设置
loadOemSettings()
// 检查 URL 参数
const urlApiId = route.query.apiId
const urlApiKey = route.query.apiKey
if (urlApiId && urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
// 如果 URL 中有 apiId直接使用 apiId 加载数据
apiId.value = urlApiId
loadStatsWithApiId()
} else if (urlApiKey && urlApiKey.length > 10) {
// 向后兼容,支持 apiKey 参数
apiKey.value = urlApiKey
}
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown)
})
// 清理
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
// 监听 API Key 变化
watch(apiKey, (newValue) => {
if (!newValue) {
apiStatsStore.clearData()
}
})
</script>
<style scoped>
/* 渐变背景 */
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-attachment: fixed;
min-height: 100vh;
position: relative;
}
.gradient-bg::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* 玻璃态效果 */
.glass-strong {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
}
/* 标题渐变 */
.header-title {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
letter-spacing: -0.025em;
}
/* 管理后台按钮 */
.admin-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
text-decoration: none;
box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.3), 0 2px 4px -1px rgba(102, 126, 234, 0.1);
position: relative;
overflow: hidden;
}
.admin-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.admin-button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(102, 126, 234, 0.4), 0 4px 6px -2px rgba(102, 126, 234, 0.15);
}
.admin-button:hover::before {
left: 100%;
}
/* 时间范围按钮 */
.period-btn {
position: relative;
overflow: hidden;
border-radius: 12px;
font-weight: 500;
letter-spacing: 0.025em;
transition: all 0.3s ease;
border: none;
cursor: pointer;
}
.period-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
transform: translateY(-1px);
}
.period-btn:not(.active) {
color: #374151;
background: transparent;
}
.period-btn:not(.active):hover {
background: rgba(255, 255, 255, 0.1);
color: #1f2937;
}
/* Tab 胶囊按钮样式 */
.tab-pill-button {
padding: 0.625rem 1.25rem;
border-radius: 9999px;
font-weight: 500;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.tab-pill-button:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.tab-pill-button.active {
background: white;
color: #764ba2;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.tab-pill-button i {
font-size: 0.875rem;
}
/* Tab 内容切换动画 */
.tab-content {
animation: tabFadeIn 0.4s ease-out;
}
@keyframes tabFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 动画效果 */
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,744 @@
<template>
<div>
<!-- 主要统计 -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">总API Keys</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
| 限流: {{ dashboardData.rateLimitedAccounts }}
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
<i class="fas fa-user-circle"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">今日请求</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
<i class="fas fa-chart-line"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">系统状态</p>
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formattedUptime }}</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat"></i>
</div>
</div>
</div>
</div>
<!-- Token统计和性能指标 -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.todayInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.todayOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
<i class="fas fa-coins"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.totalInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.totalOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
<i class="fas fa-database"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均RPM</p>
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">每分钟请求数</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
<i class="fas fa-tachometer-alt"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均TPM</p>
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">每分钟Token数</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
<i class="fas fa-rocket"></i>
</div>
</div>
</div>
</div>
<!-- 模型消费统计 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">模型使用分布与Token使用趋势</h3>
<div class="flex gap-2 items-center">
<!-- 快捷日期选择 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
v-for="option in dateFilter.presetOptions"
:key="option.value"
@click="setDateFilterPreset(option.value)"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
dateFilter.preset === option.value && dateFilter.type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
{{ option.label }}
</button>
</div>
<!-- 粒度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="setTrendGranularity('day')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-calendar-day mr-1"></i>按天
</button>
<button
@click="setTrendGranularity('hour')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-clock mr-1"></i>按小时
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<div class="flex items-center gap-2">
<el-date-picker
:default-time="defaultTime"
v-model="dateFilter.customRange"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="onCustomDateRangeChange"
:disabled-date="disabledDate"
size="default"
style="width: 400px;"
class="custom-date-picker"
></el-date-picker>
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
<i class="fas fa-info-circle"></i> 最多24小时
</span>
</div>
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
<i class="fas fa-sync-alt"></i>刷新
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 饼图 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">Token使用分布</h4>
<div class="relative" style="height: 300px;">
<canvas ref="modelUsageChart"></canvas>
</div>
</div>
<!-- 详细数据表格 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">详细统计数据</h4>
<div v-if="dashboardModelStats.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无模型使用数据</p>
</div>
<div v-else class="overflow-auto max-h-[300px]">
<table class="min-w-full">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">模型</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">请求数</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">总Token</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">费用</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">占比</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">{{ stat.model }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.requests) }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.allTokens) }}</td>
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">{{ stat.formatted ? stat.formatted.total : '$0.000000' }}</td>
<td class="px-4 py-2 text-sm font-medium text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Token使用趋势图 -->
<div class="mb-8">
<div class="card p-6">
<div style="height: 300px;">
<canvas ref="usageTrendChart"></canvas>
</div>
</div>
</div>
<!-- API Keys 使用趋势图 -->
<div class="mb-8">
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
<!-- 维度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-exchange-alt mr-1"></i>请求次数
</button>
<button
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-coins mr-1"></i>Token 数量
</button>
</div>
</div>
<div class="mb-4 text-sm text-gray-600">
<span v-if="apiKeysTrendData.totalApiKeys > 10">
{{ apiKeysTrendData.totalApiKeys }} API Key显示使用量前 10
</span>
<span v-else>
{{ apiKeysTrendData.totalApiKeys }} API Key
</span>
</div>
<div style="height: 350px;">
<canvas ref="apiKeysUsageTrendChart"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore()
const {
dashboardData,
costsData,
dashboardModelStats,
trendData,
apiKeysTrendData,
formattedUptime,
dateFilter,
trendGranularity,
apiKeysTrendMetric,
defaultTime
} = storeToRefs(dashboardStore)
const {
loadDashboardData,
loadUsageTrend,
loadModelStats,
loadApiKeysTrend,
setDateFilterPreset,
onCustomDateRangeChange,
setTrendGranularity,
refreshChartsData,
disabledDate
} = dashboardStore
// Chart 实例
const modelUsageChart = ref(null)
const usageTrendChart = ref(null)
const apiKeysUsageTrendChart = ref(null)
let modelUsageChartInstance = null
let usageTrendChartInstance = null
let apiKeysUsageTrendChartInstance = null
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K'
}
return num.toString()
}
// 计算百分比
function calculatePercentage(value, stats) {
if (!stats || stats.length === 0) return 0
const total = stats.reduce((sum, stat) => sum + stat.allTokens, 0)
if (total === 0) return 0
return ((value / total) * 100).toFixed(1)
}
// 创建模型使用饼图
function createModelUsageChart() {
if (!modelUsageChart.value) return
if (modelUsageChartInstance) {
modelUsageChartInstance.destroy()
}
const data = dashboardModelStats.value || []
const chartData = {
labels: data.map(d => d.model),
datasets: [{
data: data.map(d => d.allTokens),
backgroundColor: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
],
borderWidth: 0
}]
}
modelUsageChartInstance = new Chart(modelUsageChart.value, {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 15,
usePointStyle: true,
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || ''
const value = formatNumber(context.parsed)
const percentage = calculatePercentage(context.parsed, data)
return `${label}: ${value} (${percentage}%)`
}
}
}
}
}
})
}
// 创建使用趋势图
function createUsageTrendChart() {
if (!usageTrendChart.value) return
if (usageTrendChartInstance) {
usageTrendChartInstance.destroy()
}
const data = trendData.value || []
// 准备多维度数据
const inputData = data.map(d => d.inputTokens || 0)
const outputData = data.map(d => d.outputTokens || 0)
const cacheCreateData = data.map(d => d.cacheCreateTokens || 0)
const cacheReadData = data.map(d => d.cacheReadTokens || 0)
const requestsData = data.map(d => d.requests || 0)
const costData = data.map(d => d.cost || 0)
const chartData = {
labels: data.map(d => d.date),
datasets: [
{
label: '输入Token',
data: inputData,
borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.3
},
{
label: '输出Token',
data: outputData,
borderColor: 'rgb(240, 147, 251)',
backgroundColor: 'rgba(240, 147, 251, 0.1)',
tension: 0.3
},
{
label: '缓存创建Token',
data: cacheCreateData,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3
},
{
label: '缓存读取Token',
data: cacheReadData,
borderColor: 'rgb(147, 51, 234)',
backgroundColor: 'rgba(147, 51, 234, 0.1)',
tension: 0.3
},
{
label: '费用 (USD)',
data: costData,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.3,
yAxisID: 'y2'
},
{
label: '请求数',
data: requestsData,
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
yAxisID: 'y1'
}
]
}
usageTrendChartInstance = new Chart(usageTrendChart.value, {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
title: {
display: true,
text: 'Token使用趋势',
font: {
size: 16,
weight: 'bold'
}
},
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
let value = context.parsed.y
if (label === '费用 (USD)') {
// 格式化费用显示
if (value < 0.01) {
return label + ': $' + value.toFixed(6)
} else {
return label + ': $' + value.toFixed(4)
}
} else if (label === '请求数') {
return label + ': ' + value.toLocaleString() + ' 次'
} else {
return label + ': ' + value.toLocaleString() + ' tokens'
}
}
}
}
},
scales: {
x: {
type: 'category',
display: true,
title: {
display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期'
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Token数量'
},
ticks: {
callback: function(value) {
return formatNumber(value)
}
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '请求数'
},
grid: {
drawOnChartArea: false,
},
ticks: {
callback: function(value) {
return value.toLocaleString()
}
}
},
y2: {
type: 'linear',
display: false, // 隐藏费用轴在tooltip中显示
position: 'right'
}
}
}
})
}
// 创建API Keys使用趋势图
function createApiKeysUsageTrendChart() {
if (!apiKeysUsageTrendChart.value) return
if (apiKeysUsageTrendChartInstance) {
apiKeysUsageTrendChartInstance.destroy()
}
const data = apiKeysTrendData.value.data || []
const metric = apiKeysTrendMetric.value
// 颜色数组
const colors = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
]
// 准备数据集
const datasets = apiKeysTrendData.value.topApiKeys?.map((apiKeyId, index) => {
const data = apiKeysTrendData.value.data.map(item => {
if (!item.apiKeys || !item.apiKeys[apiKeyId]) return 0
return metric === 'tokens'
? item.apiKeys[apiKeyId].tokens
: item.apiKeys[apiKeyId].requests || 0
})
// 获取API Key名称
const apiKeyName = apiKeysTrendData.value.data.find(item =>
item.apiKeys && item.apiKeys[apiKeyId]
)?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`
return {
label: apiKeyName,
data: data,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length] + '20',
tension: 0.4,
fill: false
}
}) || []
const chartData = {
labels: data.map(d => d.date),
datasets: datasets
}
apiKeysUsageTrendChartInstance = new Chart(apiKeysUsageTrendChart.value, {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true,
font: {
size: 12
}
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
const value = context.parsed.y
const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次'
return label + ': ' + value.toLocaleString() + unit
}
}
}
},
scales: {
x: {
type: 'category',
display: true,
title: {
display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期'
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数'
},
ticks: {
callback: function(value) {
return formatNumber(value)
}
}
}
}
}
})
}
// 更新API Keys使用趋势图
async function updateApiKeysUsageTrendChart() {
await loadApiKeysTrend(apiKeysTrendMetric.value)
await nextTick()
createApiKeysUsageTrendChart()
}
// 监听数据变化更新图表
watch(dashboardModelStats, () => {
nextTick(() => createModelUsageChart())
})
watch(trendData, () => {
nextTick(() => createUsageTrendChart())
})
watch(apiKeysTrendData, () => {
nextTick(() => createApiKeysUsageTrendChart())
})
// 初始化
onMounted(async () => {
// 加载所有数据
await Promise.all([
loadDashboardData(),
refreshChartsData() // 使用refreshChartsData来确保根据当前筛选条件加载数据
])
// 创建图表
await nextTick()
createModelUsageChart()
createUsageTrendChart()
createApiKeysUsageTrendChart()
})
</script>
<style scoped>
/* 自定义日期选择器样式 */
.custom-date-picker :deep(.el-input__inner) {
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
font-size: 13px;
padding: 0 10px;
}
.custom-date-picker :deep(.el-range-separator) {
@apply text-gray-500;
padding: 0 2px;
}
.custom-date-picker :deep(.el-range-input) {
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="flex items-center justify-center min-h-screen p-6">
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
<div class="text-center mb-8">
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
<template v-if="!oemLoading">
<img v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
</template>
<div v-else class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"></div>
</div>
<template v-if="!oemLoading && authStore.oemSettings.siteName">
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ authStore.oemSettings.siteName }}</h1>
</template>
<div v-else-if="oemLoading" class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"></div>
<p class="text-gray-600 text-lg">管理后台</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<div>
<label class="block text-sm font-semibold text-gray-900 mb-3">用户名</label>
<input
v-model="loginForm.username"
type="text"
required
class="form-input w-full"
placeholder="请输入用户名"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-900 mb-3">密码</label>
<input
v-model="loginForm.password"
type="password"
required
class="form-input w-full"
placeholder="请输入密码"
>
</div>
<button
type="submit"
:disabled="authStore.loginLoading"
class="btn btn-primary w-full py-4 px-6 text-lg font-semibold"
>
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2"></i>
<div v-if="authStore.loginLoading" class="loading-spinner mr-2"></div>
{{ authStore.loginLoading ? '登录中...' : '登录' }}
</button>
</form>
<div v-if="authStore.loginError" class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>{{ authStore.loginError }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LogoTitle from '@/components/common/LogoTitle.vue'
const authStore = useAuthStore()
const oemLoading = computed(() => authStore.oemLoading)
const loginForm = ref({
username: '',
password: ''
})
onMounted(() => {
// 加载OEM设置
authStore.loadOemSettings()
})
const handleLogin = async () => {
await authStore.login(loginForm.value)
}
</script>
<style scoped>
/* 组件特定样式已经在全局样式中定义 */
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div class="settings-container">
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
<p class="text-gray-600">自定义网站名称和图标</p>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载设置...</p>
</div>
<div v-else class="table-container">
<table class="min-w-full">
<tbody class="divide-y divide-gray-200/50">
<!-- 网站名称 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-font text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站名称</div>
<div class="text-xs text-gray-500">品牌标识</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<input
v-model="oemSettings.siteName"
type="text"
class="form-input w-full max-w-md"
placeholder="Claude Relay Service"
maxlength="100"
>
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
</td>
</tr>
<!-- 网站图标 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-image text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站图标</div>
<div class="text-xs text-gray-500">Favicon</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="space-y-3">
<!-- 图标预览 -->
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<img
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="图标预览"
class="w-8 h-8"
@error="handleIconError"
>
<span class="text-sm text-gray-600">当前图标</span>
<button
@click="removeIcon"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
>
<i class="fas fa-trash mr-1"></i>删除
</button>
</div>
<!-- 文件上传 -->
<div>
<input
type="file"
ref="iconFileInput"
@change="handleIconUpload"
accept=".ico,.png,.jpg,.jpeg,.svg"
class="hidden"
>
<button
@click="$refs.iconFileInput.click()"
class="btn btn-success px-4 py-2"
>
<i class="fas fa-upload mr-2"></i>
上传图标
</button>
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式最大 350KB</span>
</div>
</div>
</td>
</tr>
<!-- 操作按钮 -->
<tr>
<td class="px-6 py-6" colspan="2">
<div class="flex items-center justify-between">
<div class="flex gap-3">
<button
@click="saveOemSettings"
:disabled="saving"
class="btn btn-primary px-6 py-3"
:class="{ 'opacity-50 cursor-not-allowed': saving }"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
{{ saving ? '保存中...' : '保存设置' }}
</button>
<button
@click="resetOemSettings"
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
:disabled="saving"
>
<i class="fas fa-undo mr-2"></i>
重置为默认
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
最后更新{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { showToast } from '@/utils/toast'
import { useSettingsStore } from '@/stores/settings'
// 使用settings store
const settingsStore = useSettingsStore()
const { loading, saving, oemSettings } = storeToRefs(settingsStore)
// 组件refs
const iconFileInput = ref()
// 页面加载时获取设置
onMounted(async () => {
try {
await settingsStore.loadOemSettings()
} catch (error) {
showToast('加载设置失败', 'error')
}
})
// 保存OEM设置
const saveOemSettings = async () => {
try {
const settings = {
siteName: oemSettings.value.siteName,
siteIcon: oemSettings.value.siteIcon,
siteIconData: oemSettings.value.siteIconData
}
const result = await settingsStore.saveOemSettings(settings)
if (result && result.success) {
showToast('OEM设置保存成功', 'success')
} else {
showToast(result?.message || '保存失败', 'error')
}
} catch (error) {
showToast('保存OEM设置失败', 'error')
}
}
// 重置OEM设置
const resetOemSettings = async () => {
if (!confirm('确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。')) return
try {
const result = await settingsStore.resetOemSettings()
if (result && result.success) {
showToast('已重置为默认设置', 'success')
} else {
showToast('重置失败', 'error')
}
} catch (error) {
showToast('重置失败', 'error')
}
}
// 处理图标上传
const handleIconUpload = async (event) => {
const file = event.target.files[0]
if (!file) return
// 验证文件
const validation = settingsStore.validateIconFile(file)
if (!validation.isValid) {
validation.errors.forEach(error => showToast(error, 'error'))
return
}
try {
// 转换为Base64
const base64Data = await settingsStore.fileToBase64(file)
oemSettings.value.siteIconData = base64Data
} catch (error) {
showToast('文件读取失败', 'error')
}
// 清除input的值允许重复选择同一文件
event.target.value = ''
}
// 删除图标
const removeIcon = () => {
oemSettings.value.siteIcon = ''
oemSettings.value.siteIconData = ''
}
// 处理图标加载错误
const handleIconError = () => {
console.warn('Icon failed to load')
}
// 格式化日期时间
const formatDateTime = settingsStore.formatDateTime
</script>
<style scoped>
.settings-container {
min-height: calc(100vh - 300px);
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
}
.table-container {
overflow: hidden;
border-radius: 8px;
border: 1px solid #f3f4f6;
}
.table-row {
transition: background-color 0.2s ease;
}
.table-row:hover {
background-color: #f9fafb;
}
.form-input {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200;
}
.btn {
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
}
.loading-spinner {
@apply w-5 h-5 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin;
}
</style>

View File

@@ -0,0 +1,828 @@
<template>
<div class="card p-6">
<div class="mb-8">
<h3 class="text-2xl font-bold text-gray-900 mb-4 flex items-center">
<i class="fas fa-graduation-cap text-blue-600 mr-3"></i>
Claude Code 使用教程
</h3>
<p class="text-gray-600 text-lg">跟着这个教程你可以轻松在自己的电脑上安装并使用 Claude Code</p>
</div>
<!-- 系统选择标签 -->
<div class="mb-8">
<div class="flex flex-wrap gap-2 p-2 bg-gray-100 rounded-xl">
<button
v-for="system in tutorialSystems"
:key="system.key"
@click="activeTutorialSystem = system.key"
:class="['flex-1 py-3 px-6 text-sm font-semibold rounded-lg transition-all duration-300 flex items-center justify-center gap-2',
activeTutorialSystem === system.key
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900']"
>
<i :class="system.icon"></i>
{{ system.name }}
</button>
</div>
</div>
<!-- Windows 教程 -->
<div v-if="activeTutorialSystem === 'windows'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
安装 Node.js 环境
</h4>
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行</p>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fab fa-windows text-blue-600 mr-2"></i>
Windows 安装方法
</h5>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法一官网下载推荐</p>
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
<li>打开浏览器访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
<li>点击 "LTS" 版本进行下载推荐长期支持版本</li>
<li>下载完成后双击 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.msi</code> 文件</li>
<li>按照安装向导完成安装保持默认设置即可</li>
</ol>
</div>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法二使用包管理器</p>
<p class="text-gray-600 mb-2">如果你安装了 Chocolatey Scoop可以使用命令行安装</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="mb-2"># 使用 Chocolatey</div>
<div class="text-gray-300">choco install nodejs</div>
<div class="mt-3 mb-2"># 或使用 Scoop</div>
<div class="text-gray-300">scoop install nodejs</div>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h6 class="font-medium text-blue-800 mb-2">Windows 注意事项</h6>
<ul class="text-blue-700 text-sm space-y-1">
<li> 建议使用 PowerShell 而不是 CMD</li>
<li> 如果遇到权限问题尝试以管理员身份运行</li>
<li> 某些杀毒软件可能会误报需要添加白名单</li>
</ul>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
<p class="text-green-700 text-sm mb-3">安装完成后打开 PowerShell CMD输入以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">node --version</div>
<div class="text-gray-300">npm --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号说明安装成功了</p>
</div>
</div>
<!-- 第二步安装 Git Bash -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
安装 Git Bash
</h4>
<p class="text-gray-600 mb-6">Windows 环境下需要使用 Git Bash 安装Claude code安装完成后环境变量设置和使用 Claude Code 仍然在普通的 PowerShell CMD 中进行</p>
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl p-6 border border-green-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fab fa-git-alt text-green-600 mr-2"></i>
下载并安装 Git for Windows
</h5>
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4 mb-4">
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://git-scm.com/downloads/win</code></li>
<li>点击 "Download for Windows" 下载安装包</li>
<li>运行下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.exe</code> 安装文件</li>
<li>在安装过程中保持默认设置直接点击 "Next" 完成安装</li>
</ol>
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">安装完成后</h6>
<ul class="text-green-700 text-sm space-y-1">
<li> 在任意文件夹右键可以看到 "Git Bash Here" 选项</li>
<li> 也可以从开始菜单启动 "Git Bash"</li>
<li> 只需要在 Git Bash 中运行 npm install 命令</li>
<li> 后续的环境变量设置和使用都在 PowerShell/CMD </li>
</ul>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证 Git Bash 安装</h6>
<p class="text-green-700 text-sm mb-3">打开 Git Bash输入以下命令验证</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">git --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示 Git 版本号说明安装成功</p>
</div>
</div>
<!-- 第三步安装 Claude Code -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
安装 Claude Code
</h4>
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-download text-purple-600 mr-2"></i>
安装 Claude Code
</h5>
<p class="text-gray-700 mb-4">打开 Git Bash重要不要使用 PowerShell运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
<div class="mb-2"># Git Bash 中全局安装 Claude Code</div>
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
</div>
<p class="text-gray-600 text-sm">这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code</p>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
<h6 class="font-medium text-yellow-800 mb-2">重要提醒</h6>
<ul class="text-yellow-700 text-sm space-y-1">
<li> 必须在 Git Bash 中运行不要在 PowerShell 中运行</li>
<li> 如果遇到权限问题可以尝试在 Git Bash 中使用 sudo 命令</li>
</ul>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
<p class="text-green-700 text-sm mb-3">安装完成后输入以下命令检查是否安装成功</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号恭喜你Claude Code 已经成功安装了</p>
</div>
</div>
<!-- 第四步设置环境变量 -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
设置环境变量
</h4>
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-cog text-orange-600 mr-2"></i>
配置 Claude Code 环境变量
</h5>
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务需要设置两个环境变量</p>
<div class="space-y-4">
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法一PowerShell 临时设置推荐</h6>
<p class="text-gray-600 text-sm mb-3"> PowerShell 中运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">$env:ANTHROPIC_BASE_URL = "{{ currentBaseUrl }}"</div>
<div class="text-gray-300">$env:ANTHROPIC_AUTH_TOKEN = "你的API密钥"</div>
</div>
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥</p>
</div>
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法二系统环境变量永久设置</h6>
<ol class="text-gray-600 text-sm space-y-1 list-decimal list-inside">
<li>右键"此电脑" "属性" "高级系统设置"</li>
<li>点击"环境变量"按钮</li>
<li>"用户变量""系统变量"中点击"新建"</li>
<li>添加以下两个变量</li>
</ol>
<div class="mt-3 space-y-2">
<div class="bg-gray-100 p-2 rounded text-sm">
<strong>变量名</strong> ANTHROPIC_BASE_URL<br>
<strong>变量值</strong> <span class="font-mono">{{ currentBaseUrl }}</span>
</div>
<div class="bg-gray-100 p-2 rounded text-sm">
<strong>变量名</strong> ANTHROPIC_AUTH_TOKEN<br>
<strong>变量值</strong> <span class="font-mono">你的API密钥</span>
</div>
</div>
</div>
</div>
</div>
<!-- 验证环境变量设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
<h6 class="font-medium text-blue-800 mb-2">验证环境变量设置</h6>
<p class="text-blue-700 text-sm mb-3">设置完环境变量后可以通过以下命令验证是否设置成功</p>
<div class="space-y-4">
<div>
<h6 class="font-medium text-gray-800 mb-2"> PowerShell 中验证</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
<div class="text-gray-300">echo $env:ANTHROPIC_BASE_URL</div>
<div class="text-gray-300">echo $env:ANTHROPIC_AUTH_TOKEN</div>
</div>
</div>
<div>
<h6 class="font-medium text-gray-800 mb-2"> CMD 中验证</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm space-y-1">
<div class="text-gray-300">echo %ANTHROPIC_BASE_URL%</div>
<div class="text-gray-300">echo %ANTHROPIC_AUTH_TOKEN%</div>
</div>
</div>
</div>
<div class="mt-3 space-y-2">
<p class="text-blue-700 text-sm">
<strong>预期输出示例</strong>
</p>
<div class="bg-gray-100 p-2 rounded text-sm font-mono">
<div>{{ currentBaseUrl }}</div>
<div>cr_xxxxxxxxxxxxxxxxxx</div>
</div>
<p class="text-blue-700 text-xs">
💡 如果输出为空或显示变量名本身说明环境变量设置失败请重新设置
</p>
</div>
</div>
</div>
<!-- 第五步开始使用 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">5</span>
开始使用 Claude Code
</h4>
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code </p>
<div class="space-y-4">
<div>
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude</div>
</div>
</div>
<div>
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># 进入你的项目目录</div>
<div class="text-gray-300">cd C:\path\to\your\project</div>
<div class="mt-2 mb-2"># 启动 Claude Code</div>
<div class="text-gray-300">claude</div>
</div>
</div>
</div>
</div>
</div>
<!-- Windows 故障排除 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-wrench text-red-600 mr-3"></i>
Windows 常见问题解决
</h4>
<div class="space-y-4">
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
安装时提示 "permission denied" 错误
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">这通常是权限问题尝试以下解决方法</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>以管理员身份运行 PowerShell</li>
<li>或者配置 npm 使用用户目录<code class="bg-gray-200 px-1 rounded">npm config set prefix %APPDATA%\npm</code></li>
</ul>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
PowerShell 执行策略错误
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">如果遇到执行策略限制运行</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser</div>
</div>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
环境变量设置后不生效
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">设置永久环境变量后需要</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>重新启动 PowerShell CMD</li>
<li>或者注销并重新登录 Windows</li>
<li>验证设置<code class="bg-gray-200 px-1 rounded">echo $env:ANTHROPIC_BASE_URL</code></li>
</ul>
</div>
</details>
</div>
</div>
</div>
<!-- macOS 教程 -->
<div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
安装 Node.js 环境
</h4>
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行</p>
<div class="bg-gradient-to-r from-gray-50 to-slate-50 rounded-xl p-6 border border-gray-200 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fab fa-apple text-gray-700 mr-2"></i>
macOS 安装方法
</h5>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法一使用 Homebrew推荐</p>
<p class="text-gray-600 mb-2">如果你已经安装了 Homebrew使用它安装 Node.js 会更方便</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="mb-2"># 更新 Homebrew</div>
<div class="text-gray-300">brew update</div>
<div class="mt-3 mb-2"># 安装 Node.js</div>
<div class="text-gray-300">brew install node</div>
</div>
</div>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法二官网下载</p>
<ol class="list-decimal list-inside text-gray-600 space-y-2 ml-4">
<li>访问 <code class="bg-gray-100 px-2 py-1 rounded text-sm">https://nodejs.org/</code></li>
<li>下载适合 macOS LTS 版本</li>
<li>打开下载的 <code class="bg-gray-100 px-2 py-1 rounded text-sm">.pkg</code> 文件</li>
<li>按照安装程序指引完成安装</li>
</ol>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h6 class="font-medium text-gray-800 mb-2">macOS 注意事项</h6>
<ul class="text-gray-700 text-sm space-y-1">
<li> 如果遇到权限问题可能需要使用 <code class="bg-gray-200 px-1 rounded">sudo</code></li>
<li> 首次运行可能需要在系统偏好设置中允许</li>
<li> 建议使用 Terminal iTerm2</li>
</ul>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
<p class="text-green-700 text-sm mb-3">安装完成后打开 Terminal输入以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">node --version</div>
<div class="text-gray-300">npm --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号说明安装成功了</p>
</div>
</div>
<!-- 第二步安装 Claude Code -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
安装 Claude Code
</h4>
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-download text-purple-600 mr-2"></i>
安装 Claude Code
</h5>
<p class="text-gray-700 mb-4">打开 Terminal运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
<div class="mb-2"># 全局安装 Claude Code</div>
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
</div>
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题可以使用 sudo</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
<p class="text-green-700 text-sm mb-3">安装完成后输入以下命令检查是否安装成功</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号恭喜你Claude Code 已经成功安装了</p>
</div>
</div>
<!-- 第三步设置环境变量 -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
设置环境变量
</h4>
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-cog text-orange-600 mr-2"></i>
配置 Claude Code 环境变量
</h5>
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务需要设置两个环境变量</p>
<div class="space-y-4">
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法一临时设置当前会话</h6>
<p class="text-gray-600 text-sm mb-3"> Terminal 中运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
</div>
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥</p>
</div>
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法二永久设置</h6>
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件根据你使用的 shell</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
<div class="mb-2"># 对于 zsh (默认)</div>
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
<div class="text-gray-300">source ~/.zshrc</div>
</div>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># 对于 bash</div>
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bash_profile</div>
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bash_profile</div>
<div class="text-gray-300">source ~/.bash_profile</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第四步开始使用 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
开始使用 Claude Code
</h4>
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code </p>
<div class="space-y-4">
<div>
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude</div>
</div>
</div>
<div>
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># 进入你的项目目录</div>
<div class="text-gray-300">cd /path/to/your/project</div>
<div class="mt-2 mb-2"># 启动 Claude Code</div>
<div class="text-gray-300">claude</div>
</div>
</div>
</div>
</div>
</div>
<!-- macOS 故障排除 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-wrench text-red-600 mr-3"></i>
macOS 常见问题解决
</h4>
<div class="space-y-4">
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
安装时提示权限错误
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">尝试以下解决方法</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>使用 sudo 安装<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
<li>或者配置 npm 使用用户目录<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
</ul>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
macOS 安全设置阻止运行
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">如果系统阻止运行 Claude Code</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>打开"系统偏好设置" "安全性与隐私"</li>
<li>点击"仍要打开""允许"</li>
<li>或者在 Terminal 中运行<code class="bg-gray-200 px-1 rounded">sudo spctl --master-disable</code></li>
</ul>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
环境变量不生效
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">检查以下几点</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>确认修改了正确的配置文件.zshrc .bash_profile</li>
<li>重新启动 Terminal</li>
<li>验证设置<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
</ul>
</div>
</details>
</div>
</div>
</div>
<!-- Linux 教程 -->
<div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">1</span>
安装 Node.js 环境
</h4>
<p class="text-gray-600 mb-6">Claude Code 需要 Node.js 环境才能运行</p>
<div class="bg-gradient-to-r from-orange-50 to-red-50 rounded-xl p-6 border border-orange-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fab fa-ubuntu text-orange-600 mr-2"></i>
Linux 安装方法
</h5>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法一使用官方仓库推荐</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="mb-2"># 添加 NodeSource 仓库</div>
<div class="text-gray-300">curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -</div>
<div class="mt-3 mb-2"># 安装 Node.js</div>
<div class="text-gray-300">sudo apt-get install -y nodejs</div>
</div>
</div>
<div class="mb-4">
<p class="text-gray-700 mb-3">方法二使用系统包管理器</p>
<p class="text-gray-600 mb-2">虽然版本可能不是最新的但对于基本使用已经足够</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="mb-2"># Ubuntu/Debian</div>
<div class="text-gray-300">sudo apt update</div>
<div class="text-gray-300">sudo apt install nodejs npm</div>
<div class="mt-3 mb-2"># CentOS/RHEL/Fedora</div>
<div class="text-gray-300">sudo dnf install nodejs npm</div>
</div>
</div>
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
<h6 class="font-medium text-orange-800 mb-2">Linux 注意事项</h6>
<ul class="text-orange-700 text-sm space-y-1">
<li> 某些发行版可能需要安装额外的依赖</li>
<li> 如果遇到权限问题使用 <code class="bg-orange-200 px-1 rounded">sudo</code></li>
<li> 确保你的用户在 npm 的全局目录有写权限</li>
</ul>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证安装是否成功</h6>
<p class="text-green-700 text-sm mb-3">安装完成后打开终端输入以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">node --version</div>
<div class="text-gray-300">npm --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号说明安装成功了</p>
</div>
</div>
<!-- 第二步安装 Claude Code -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">2</span>
安装 Claude Code
</h4>
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-download text-purple-600 mr-2"></i>
安装 Claude Code
</h5>
<p class="text-gray-700 mb-4">打开终端运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm mb-4">
<div class="mb-2"># 全局安装 Claude Code</div>
<div class="text-gray-300">npm install -g @anthropic-ai/claude-code</div>
</div>
<p class="text-gray-600 text-sm mb-2">如果遇到权限问题可以使用 sudo</p>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
<div class="text-gray-300">sudo npm install -g @anthropic-ai/claude-code</div>
</div>
</div>
<!-- 验证安装 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<h6 class="font-medium text-green-800 mb-2">验证 Claude Code 安装</h6>
<p class="text-green-700 text-sm mb-3">安装完成后输入以下命令检查是否安装成功</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude --version</div>
</div>
<p class="text-green-700 text-sm mt-2">如果显示版本号恭喜你Claude Code 已经成功安装了</p>
</div>
</div>
<!-- 第三步设置环境变量 -->
<div class="mb-10">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">3</span>
设置环境变量
</h4>
<div class="bg-gradient-to-r from-orange-50 to-yellow-50 rounded-xl p-6 border border-orange-100 mb-6">
<h5 class="text-lg font-semibold text-gray-800 mb-3 flex items-center">
<i class="fas fa-cog text-orange-600 mr-2"></i>
配置 Claude Code 环境变量
</h5>
<p class="text-gray-700 mb-4">为了让 Claude Code 连接到你的中转服务需要设置两个环境变量</p>
<div class="space-y-4">
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法一临时设置当前会话</h6>
<p class="text-gray-600 text-sm mb-3">在终端中运行以下命令</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"</div>
<div class="text-gray-300">export ANTHROPIC_AUTH_TOKEN="你的API密钥"</div>
</div>
<p class="text-yellow-700 text-xs mt-2">💡 记得将 "你的API密钥" 替换为在上方 "API Keys" 标签页中创建的实际密钥</p>
</div>
<div class="bg-white rounded-lg p-4 border border-orange-200">
<h6 class="font-medium text-gray-800 mb-2">方法二永久设置</h6>
<p class="text-gray-600 text-sm mb-3">编辑你的 shell 配置文件</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
<div class="mb-2"># 对于 bash (默认)</div>
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.bashrc</div>
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.bashrc</div>
<div class="text-gray-300">source ~/.bashrc</div>
</div>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># 对于 zsh</div>
<div class="text-gray-300">echo 'export ANTHROPIC_BASE_URL="{{ currentBaseUrl }}"' >> ~/.zshrc</div>
<div class="text-gray-300">echo 'export ANTHROPIC_AUTH_TOKEN="你的API密钥"' >> ~/.zshrc</div>
<div class="text-gray-300">source ~/.zshrc</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第四步开始使用 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="w-8 h-8 bg-yellow-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-3">4</span>
开始使用 Claude Code
</h4>
<div class="bg-gradient-to-r from-yellow-50 to-amber-50 rounded-xl p-6 border border-yellow-100">
<p class="text-gray-700 mb-4">现在你可以开始使用 Claude Code </p>
<div class="space-y-4">
<div>
<h6 class="font-medium text-gray-800 mb-2">启动 Claude Code</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="text-gray-300">claude</div>
</div>
</div>
<div>
<h6 class="font-medium text-gray-800 mb-2">在特定项目中使用</h6>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># 进入你的项目目录</div>
<div class="text-gray-300">cd /path/to/your/project</div>
<div class="mt-2 mb-2"># 启动 Claude Code</div>
<div class="text-gray-300">claude</div>
</div>
</div>
</div>
</div>
</div>
<!-- Linux 故障排除 -->
<div class="mb-8">
<h4 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i class="fas fa-wrench text-red-600 mr-3"></i>
Linux 常见问题解决
</h4>
<div class="space-y-4">
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
安装时提示权限错误
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">尝试以下解决方法</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>使用 sudo 安装<code class="bg-gray-200 px-1 rounded">sudo npm install -g @anthropic-ai/claude-code</code></li>
<li>或者配置 npm 使用用户目录<code class="bg-gray-200 px-1 rounded">npm config set prefix ~/.npm-global</code></li>
<li>然后添加到 PATH<code class="bg-gray-200 px-1 rounded">export PATH=~/.npm-global/bin:$PATH</code></li>
</ul>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
缺少依赖库
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">某些 Linux 发行版需要安装额外依赖</p>
<div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<div class="mb-2"># Ubuntu/Debian</div>
<div class="text-gray-300">sudo apt install build-essential</div>
<div class="mt-2 mb-2"># CentOS/RHEL</div>
<div class="text-gray-300">sudo dnf groupinstall "Development Tools"</div>
</div>
</div>
</details>
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="p-4 cursor-pointer font-medium text-gray-800 hover:bg-gray-100">
环境变量不生效
</summary>
<div class="px-4 pb-4 text-gray-600">
<p class="mb-2">检查以下几点</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>确认修改了正确的配置文件.bashrc .zshrc</li>
<li>重新启动终端或运行 <code class="bg-gray-200 px-1 rounded">source ~/.bashrc</code></li>
<li>验证设置<code class="bg-gray-200 px-1 rounded">echo $ANTHROPIC_BASE_URL</code></li>
</ul>
</div>
</details>
</div>
</div>
</div>
<!-- 结尾 -->
<div class="bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl p-6 text-center">
<h5 class="text-xl font-semibold mb-2">🎉 恭喜你</h5>
<p class="text-blue-100 mb-4">你已经成功安装并配置了 Claude Code现在可以开始享受 AI 编程助手带来的便利了</p>
<p class="text-sm text-blue-200">如果在使用过程中遇到任何问题可以查看官方文档或社区讨论获取帮助</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 当前系统选择
const activeTutorialSystem = ref('windows')
// 系统列表
const tutorialSystems = [
{ key: 'windows', name: 'Windows', icon: 'fab fa-windows' },
{ key: 'macos', name: 'macOS', icon: 'fab fa-apple' },
{ key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' },
]
// 当前域名
const currentDomain = computed(() => {
return window.location.origin
})
</script>
<style scoped>
.tutorial-container {
min-height: calc(100vh - 300px);
}
.tutorial-content {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
code {
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.tutorial-content h4 {
scroll-margin-top: 100px;
}
.tutorial-content .bg-gradient-to-r {
transition: all 0.2s ease;
}
.tutorial-content .bg-gradient-to-r:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>