Files
claude-relay-service/web/admin-spa/src/views/ApiStatsView.vue
Chapoly1305 e754589ad5 style: 优化公开统计概览的宽屏布局
- 移除状态概览的最大宽度限制,与其他标签页保持一致
- 重构 PublicStatsOverview 组件布局为响应式两列设计
- 顶部状态栏:服务状态左侧,平台可用性右侧
- 主内容区:今日统计(4项)与模型分布并排显示
- 图表区域独立占满宽度
- 各区块添加独立圆角背景,视觉层次更清晰

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:48:59 -05:00

701 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="min-h-screen p-4 md:p-6" :class="isDarkMode ? 'gradient-bg-dark' : 'gradient-bg'">
<!-- 顶部导航 -->
<div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<LogoTitle
:loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="
currentTab === 'stats'
? 'API Key 使用统计'
: currentTab === 'overview'
? '服务状态概览'
: '使用教程'
"
:title="oemSettings.siteName"
/>
<div class="flex items-center gap-2 md:gap-4">
<!-- 主题切换按钮 -->
<div class="flex items-center">
<ThemeToggle mode="dropdown" />
</div>
<!-- 分隔线 -->
<div
v-if="oemSettings.ldapEnabled || oemSettings.showAdminButton !== false"
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/>
<!-- 用户登录按钮 (仅在 LDAP 启用时显示) -->
<router-link
v-if="oemSettings.ldapEnabled"
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 text-white transition-all duration-300 md:px-5 md:py-2.5"
to="/user-login"
>
<i class="fas fa-user text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
</router-link>
<!-- 管理后台按钮 -->
<router-link
v-if="oemSettings.showAdminButton !== false"
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
to="/dashboard"
>
<i class="fas fa-shield-alt text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
</router-link>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="mb-6 md:mb-8">
<div class="flex justify-center">
<div
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
>
<button
:class="['tab-pill-button', currentTab === 'overview' ? 'active' : '']"
@click="switchToOverview"
>
<i class="fas fa-tachometer-alt mr-1 md:mr-2" />
<span class="text-sm md:text-base">状态概览</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
@click="currentTab = 'stats'"
>
<i class="fas fa-chart-line mr-1 md:mr-2" />
<span class="text-sm md:text-base">统计查询</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
@click="currentTab = 'tutorial'"
>
<i class="fas fa-graduation-cap mr-1 md:mr-2" />
<span class="text-sm md:text-base">使用教程</span>
</button>
</div>
</div>
</div>
<!-- 状态概览内容 -->
<div v-if="currentTab === 'overview'" class="tab-content">
<PublicStatsOverview />
</div>
<!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content">
<!-- API Key 输入区域 -->
<ApiKeyInput />
<!-- 错误提示 -->
<div v-if="error" class="mb-6 md:mb-8">
<div
class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-200 md:p-4 md:text-base"
>
<i class="fas fa-exclamation-triangle mr-2" />
{{ error }}
</div>
</div>
<!-- 统计数据展示区域 -->
<div v-if="statsData" class="fade-in">
<div class="glass-strong rounded-3xl p-4 shadow-xl md:p-6">
<!-- 时间范围选择器 -->
<div class="mb-4 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:pb-6">
<div
class="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4"
>
<div class="flex items-center gap-2 md:gap-3">
<i class="fas fa-clock text-base text-blue-500 md:text-lg" />
<span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg"
>统计时间范围</span
>
</div>
<div class="flex w-full items-center gap-2 md:w-auto">
<button
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
:class="['period-btn', { active: statsPeriod === 'daily' }]"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('daily')"
>
<i class="fas fa-calendar-day text-xs md:text-sm" />
今日
</button>
<button
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
:class="['period-btn', { active: statsPeriod === 'monthly' }]"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('monthly')"
>
<i class="fas fa-calendar-alt text-xs md:text-sm" />
本月
</button>
<!-- 测试按钮 - 仅在单Key模式下显示 -->
<button
v-if="!multiKeyMode"
class="test-btn flex items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:gap-2 md:px-6 md:text-sm"
:disabled="loading"
@click="openTestModal"
>
<i class="fas fa-vial text-xs md:text-sm" />
测试
</button>
</div>
</div>
</div>
<!-- 基本信息和统计概览 -->
<StatsOverview />
<!-- Token 分布和限制配置 -->
<div
class="mb-6 mt-6 grid grid-cols-1 gap-4 md:mb-8 md:mt-8 md:gap-6 xl:grid-cols-2 xl:items-stretch"
>
<TokenDistribution class="h-full" />
<template v-if="multiKeyMode">
<AggregatedStatsCard class="h-full" />
</template>
<template v-else>
<LimitConfig class="h-full" />
</template>
</div>
<!-- 模型使用统计 -->
<ModelUsageStats />
</div>
</div>
</div>
<!-- 教程内容 -->
<div v-if="currentTab === 'tutorial'" class="tab-content">
<div class="glass-strong rounded-3xl shadow-xl">
<TutorialView />
</div>
</div>
<!-- API Key 测试弹窗 -->
<ApiKeyTestModal
:api-key-name="statsData?.name || ''"
:api-key-value="apiKey"
:show="showTestModal"
@close="closeTestModal"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme'
import { useAuthStore } from '@/stores/auth'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.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 AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialView from './TutorialView.vue'
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
import PublicStatsOverview from '@/components/common/PublicStatsOverview.vue'
const route = useRoute()
const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore()
const authStore = useAuthStore()
// 当前标签页 - 默认显示状态概览
const currentTab = ref('overview')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
const {
apiKey,
apiId,
loading,
modelStatsLoading,
oemLoading,
error,
statsPeriod,
statsData,
oemSettings,
multiKeyMode
} = storeToRefs(apiStatsStore)
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
// 测试弹窗状态
const showTestModal = ref(false)
// 打开测试弹窗
const openTestModal = () => {
showTestModal.value = true
}
// 关闭测试弹窗
const closeTestModal = () => {
showTestModal.value = false
}
// 切换到状态概览并加载数据
const switchToOverview = () => {
currentTab.value = 'overview'
authStore.loadPublicStats()
}
// 处理键盘快捷键
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(() => {
// API Stats Page loaded
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
// 加载 OEM 设置
loadOemSettings()
// 默认加载公开统计数据
authStore.loadPublicStats()
// 检查 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-dark {
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 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;
}
/* 暗色模式的背景覆盖 */
.gradient-bg-dark::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(100, 116, 139, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(71, 85, 105, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(30, 41, 59, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* 玻璃态效果 - 使用CSS变量 */
.glass-strong {
background: var(--glass-strong-color);
backdrop-filter: blur(25px);
border: 1px solid var(--border-color);
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;
}
/* 暗色模式的玻璃态效果 */
:global(.dark) .glass-strong {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(55, 65, 81, 0.3),
inset 0 1px 0 rgba(75, 85, 99, 0.2);
}
/* 标题渐变 */
.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;
}
/* 用户登录按钮 */
.user-login-button {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
text-decoration: none;
box-shadow:
0 4px 12px rgba(52, 211, 153, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
font-weight: 600;
}
/* 暗色模式下的用户登录按钮 */
:global(.dark) .user-login-button {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
border: 1px solid rgba(52, 211, 153, 0.4);
color: white;
box-shadow:
0 4px 12px rgba(52, 211, 153, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
}
.user-login-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.user-login-button:hover {
transform: translateY(-2px) scale(1.02);
box-shadow:
0 8px 20px rgba(52, 211, 153, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
}
.user-login-button:hover::before {
opacity: 1;
}
/* 暗色模式下的悬停效果 */
:global(.dark) .user-login-button:hover {
box-shadow:
0 8px 20px rgba(52, 211, 153, 0.4),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
border-color: rgba(52, 211, 153, 0.5);
}
.user-login-button:active {
transform: translateY(-1px) scale(1);
}
/* 确保图标和文字在所有模式下都清晰可见 */
.user-login-button i,
.user-login-button span {
position: relative;
z-index: 1;
}
/* 管理后台按钮 - 精致版本 */
.admin-button-refined {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
text-decoration: none;
box-shadow:
0 4px 12px rgba(102, 126, 234, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
font-weight: 600;
}
/* 暗色模式下的管理后台按钮 */
:global(.dark) .admin-button-refined {
background: rgba(55, 65, 81, 0.8);
border: 1px solid rgba(107, 114, 128, 0.4);
color: #f3f4f6;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.admin-button-refined::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.admin-button-refined:hover {
transform: translateY(-2px) scale(1.02);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow:
0 8px 20px rgba(118, 75, 162, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: white;
}
.admin-button-refined:hover::before {
opacity: 1;
}
/* 暗色模式下的悬停效果 */
:global(.dark) .admin-button-refined:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: rgba(147, 51, 234, 0.4);
box-shadow:
0 8px 20px rgba(102, 126, 234, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
color: white;
}
.admin-button-refined:active {
transform: translateY(-1px) scale(1);
}
/* 确保图标和文字在所有模式下都清晰可见 */
.admin-button-refined i,
.admin-button-refined span {
position: relative;
z-index: 1;
}
/* 时间范围按钮 */
.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: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(229, 231, 235, 0.5);
}
:global(html.dark) .period-btn:not(.active) {
color: #e5e7eb;
background: rgba(55, 65, 81, 0.4);
border: 1px solid rgba(75, 85, 99, 0.5);
}
.period-btn:not(.active):hover {
background: rgba(255, 255, 255, 0.8);
color: #1f2937;
border-color: rgba(209, 213, 219, 0.8);
}
:global(html.dark) .period-btn:not(.active):hover {
background: rgba(75, 85, 99, 0.6);
color: #ffffff;
border-color: rgba(107, 114, 128, 0.8);
}
/* 测试按钮样式 */
.test-btn {
position: relative;
overflow: hidden;
border-radius: 12px;
font-weight: 500;
letter-spacing: 0.025em;
transition: all 0.3s ease;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: white;
box-shadow:
0 4px 10px -2px rgba(6, 182, 212, 0.3),
0 2px 4px -1px rgba(6, 182, 212, 0.1);
}
.test-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
0 8px 15px -3px rgba(6, 182, 212, 0.4),
0 4px 6px -2px rgba(6, 182, 212, 0.15);
}
.test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Tab 胶囊按钮样式 */
.tab-pill-button {
padding: 0.5rem 1rem;
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;
flex: 1;
justify-content: center;
}
/* 暗夜模式下的Tab按钮基础样式 */
:global(html.dark) .tab-pill-button {
color: rgba(209, 213, 219, 0.8);
}
@media (min-width: 768px) {
.tab-pill-button {
padding: 0.625rem 1.25rem;
flex: none;
}
}
.tab-pill-button:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
:global(html.dark) .tab-pill-button:hover {
color: #f3f4f6;
background: rgba(100, 116, 139, 0.2);
}
.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);
}
:global(html.dark) .tab-pill-button.active {
background: rgba(71, 85, 105, 0.9);
color: #f3f4f6;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.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>