Revert "合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)"

This commit is contained in:
Wesley Liddick
2025-09-10 14:37:52 +08:00
committed by GitHub
parent 61cf1166ff
commit 3c5068866c
12 changed files with 805 additions and 775 deletions

View File

@@ -110,70 +110,26 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
>名称 <span class="text-red-500">*</span></label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
<div>
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }}
</p>
</div>
<!-- 图标上传 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">
图标 (可选)
</label>
<div class="space-y-3">
<!-- 当前图标预览 -->
<div v-if="form.icon" class="flex items-center gap-3">
<div
class="h-12 w-12 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-600"
>
<img alt="API Key图标" class="h-full w-full object-cover" :src="form.icon" />
</div>
<button
class="text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
type="button"
@click="form.icon = ''"
>
移除图标
</button>
</div>
<!-- 图标上传按钮 -->
<div class="flex items-center gap-3">
<input
ref="iconInput"
accept="image/*"
class="hidden"
type="file"
@change="handleIconUpload"
/>
<button
class="flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
type="button"
@click="$refs.iconInput.click()"
>
<i class="fas fa-upload" />
选择图标
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">
支持 PNGJPG 格式建议尺寸 64x64px
</p>
</div>
</div>
</div>
<!-- 标签 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -428,56 +384,6 @@
</div>
</div>
<!-- GPT-5 High推理级别周费用限制 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>GPT-5 High推理级别周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '5'"
>
$5
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '20'"
>
$20
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '50'"
>
$50
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyGPT5HighCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 GPT-5 High推理级别的周费用限制周一到周日0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
@@ -898,7 +804,6 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { showToast } from '@/utils/toast'
import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
@@ -957,7 +862,6 @@ const form = reactive({
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
weeklyGPT5HighCostLimit: '', // 新增GPT-5 High推理级别周费用限制
expireDuration: '',
customExpireDate: '',
expiresAt: null,
@@ -973,8 +877,7 @@ const form = reactive({
modelInput: '',
enableClientRestriction: false,
allowedClients: [],
tags: [],
icon: '' // 新增图标base64编码
tags: []
})
// 加载支持的客户端和已存在的标签
@@ -995,35 +898,6 @@ onMounted(async () => {
}
})
// 处理图标上传
const handleIconUpload = (event) => {
const file = event.target.files?.[0]
if (!file) return
// 检查文件类型
if (!file.type.startsWith('image/')) {
ElMessage.error('请选择图片文件')
return
}
// 检查文件大小 (限制为 2MB)
if (file.size > 2 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 2MB')
return
}
// 读取文件并转换为 base64
const reader = new FileReader()
reader.onload = (e) => {
form.icon = e.target.result
ElMessage.success('图标上传成功')
}
reader.onerror = () => {
ElMessage.error('图标上传失败')
}
reader.readAsDataURL(file)
}
// 刷新账号列表
const refreshAccounts = async () => {
accountsLoading.value = true
@@ -1178,22 +1052,7 @@ const removeRestrictedModel = (index) => {
}
// 常用模型列表
const commonModels = ref([
// Claude 模型
'claude-opus-4-20250514',
'claude-opus-4-1-20250805',
// OpenAI 模型
'gpt-5',
'gpt-5 minimal',
'gpt-5 low',
'gpt-5 medium',
'gpt-5 high',
'gpt-4o',
'gpt-4o-mini',
'o1',
'o1-mini',
'o1-preview'
])
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
// 可用的快捷模型(过滤掉已在限制列表中的)
const availableQuickModels = computed(() => {
@@ -1296,11 +1155,6 @@ const createApiKey = async () => {
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
// 新增GPT-5 High推理级别周费用限制
weeklyGPT5HighCostLimit:
form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null
? parseFloat(form.weeklyGPT5HighCostLimit)
: 0,
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
expirationMode: form.expirationMode,
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
@@ -1309,8 +1163,7 @@ const createApiKey = async () => {
enableModelRestriction: form.enableModelRestriction,
restrictedModels: form.restrictedModels,
enableClientRestriction: form.enableClientRestriction,
allowedClients: form.allowedClients,
icon: form.icon || undefined // 新增:图标
allowedClients: form.allowedClients
}
// 处理Claude账户绑定区分OAuth和Console

View File

@@ -32,14 +32,16 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>名称</label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
<div>
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
用于识别此 API Key 的用途
</p>
@@ -320,56 +322,6 @@
</div>
</div>
<!-- GPT-5 High推理级别周费用限制 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>GPT-5 High推理级别周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '5'"
>
$5
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '20'"
>
$20
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = '50'"
>
$50
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyGPT5HighCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyGPT5HighCostLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 GPT-5 High推理级别的周费用限制周一到周日0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
@@ -762,7 +714,6 @@ const form = reactive({
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
weeklyGPT5HighCostLimit: '', // 新增GPT-5 High推理级别周费用限制
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -792,22 +743,7 @@ const removeRestrictedModel = (index) => {
}
// 常用模型列表
const commonModels = ref([
// Claude 模型
'claude-opus-4-20250514',
'claude-opus-4-1-20250805',
// OpenAI 模型
'gpt-5',
'gpt-5 minimal',
'gpt-5 low',
'gpt-5 medium',
'gpt-5 high',
'gpt-4o',
'gpt-4o-mini',
'o1',
'o1-mini',
'o1-preview'
])
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
// 可用的快捷模型(过滤掉已在限制列表中的)
const availableQuickModels = computed(() => {
@@ -894,11 +830,6 @@ const updateApiKey = async () => {
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
// 新增GPT-5 High推理级别周费用限制
weeklyGPT5HighCostLimit:
form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null
? parseFloat(form.weeklyGPT5HighCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
@@ -1122,7 +1053,6 @@ onMounted(async () => {
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.weeklyGPT5HighCostLimit = props.apiKey.weeklyGPT5HighCostLimit || '' // 新增
form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<div class="relative h-8 w-full overflow-hidden rounded-md shadow-sm" :class="containerClass">
<div class="relative h-8 w-full overflow-hidden rounded-lg shadow-sm" :class="containerClass">
<!-- 背景层 -->
<div class="absolute inset-0" :class="backgroundClass"></div>
@@ -12,17 +12,17 @@
></div>
<!-- 文字层 - 使用双层文字技术确保可读性 -->
<div class="relative z-10 flex h-full items-center justify-between px-2.5">
<div class="relative z-10 flex h-full items-center justify-between px-3">
<div class="flex items-center gap-1.5">
<i :class="['text-[10px]', iconClass]" />
<span class="text-[10px] font-semibold" :class="labelTextClass">{{ label }}</span>
<i :class="['text-xs', iconClass]" />
<span class="text-xs font-semibold" :class="labelTextClass">{{ label }}</span>
</div>
<div class="mr-1 flex items-center gap-0.5">
<span class="text-[10px] font-bold tabular-nums" :class="currentValueClass">
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold tabular-nums" :class="currentValueClass">
${{ current.toFixed(2) }}
</span>
<span class="text-[9px] font-medium" :class="limitTextClass">
/${{ limit.toFixed(2) }}
<span class="text-xs font-medium" :class="limitTextClass">
/ ${{ limit.toFixed(2) }}
</span>
</div>
</div>
@@ -48,7 +48,7 @@ const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['daily', 'opus', 'window', 'gpt5-high'].includes(value)
validator: (value) => ['daily', 'opus', 'window'].includes(value)
},
label: {
type: String,
@@ -88,8 +88,6 @@ const backgroundClass = computed(() => {
return 'bg-violet-50/50 dark:bg-violet-950/20'
case 'window':
return 'bg-sky-50/50 dark:bg-sky-950/20'
case 'gpt5-high':
return 'bg-orange-50/50 dark:bg-orange-950/20'
default:
return 'bg-gray-100/50 dark:bg-gray-800/30'
}
@@ -129,16 +127,6 @@ const progressBarClass = computed(() => {
}
}
if (props.type === 'gpt5-high') {
if (p >= 90) {
return 'bg-red-400 dark:bg-red-500'
} else if (p >= 70) {
return 'bg-amber-400 dark:bg-amber-500'
} else {
return 'bg-orange-400 dark:bg-orange-500'
}
}
return 'bg-gray-300 dark:bg-gray-400'
})
@@ -163,9 +151,6 @@ const iconClass = computed(() => {
case 'window':
colorClass = 'text-blue-700 dark:text-blue-400'
break
case 'gpt5-high':
colorClass = 'text-orange-700 dark:text-orange-400'
break
default:
colorClass = 'text-gray-600 dark:text-gray-400'
}
@@ -182,9 +167,6 @@ const iconClass = computed(() => {
case 'window':
iconName = 'fas fa-clock'
break
case 'gpt5-high':
iconName = 'fas fa-brain'
break
default:
iconName = 'fas fa-infinity'
}
@@ -209,8 +191,6 @@ const labelTextClass = computed(() => {
return 'text-purple-900 dark:text-purple-100'
case 'window':
return 'text-blue-900 dark:text-blue-100'
case 'gpt5-high':
return 'text-orange-900 dark:text-orange-100'
default:
return 'text-gray-900 dark:text-gray-100'
}
@@ -239,8 +219,6 @@ const currentValueClass = computed(() => {
return 'text-purple-800 dark:text-purple-200'
case 'window':
return 'text-blue-800 dark:text-blue-200'
case 'gpt5-high':
return 'text-orange-800 dark:text-orange-200'
default:
return 'text-gray-900 dark:text-gray-100'
}

View File

@@ -403,8 +403,8 @@
:class="[
'table-row transition-all duration-150',
index % 2 === 0
? 'bg-white dark:bg-gray-800/30'
: 'bg-gray-50/70 dark:bg-gray-800/50',
? 'bg-white dark:bg-gray-800/40'
: 'bg-gray-50/70 dark:bg-gray-700/30',
'border-b-2 border-gray-200/80 dark:border-gray-700/50',
'hover:bg-blue-50/60 hover:shadow-sm dark:hover:bg-blue-900/20'
]"
@@ -432,7 +432,7 @@
<!-- 显示所有者信息 -->
<div
v-if="isLdapEnabled && key.ownerDisplayName"
class="mt-1 text-xs text-red-600 dark:text-red-400"
class="mt-1 text-xs text-red-600"
>
<i class="fas fa-user mr-1" />
{{ key.ownerDisplayName }}
@@ -521,7 +521,7 @@
</span>
<span
v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400 dark:text-gray-500"
class="text-xs text-gray-400"
>无标签</span
>
</div>
@@ -555,7 +555,7 @@
</td>
<!-- 限制 -->
<td class="px-2 py-2" style="font-size: 12px">
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<!-- 每日费用限制进度条 -->
<LimitProgressBar
v-if="key.dailyCostLimit > 0"
@@ -574,15 +574,6 @@
type="opus"
/>
<!-- GPT-5 High 周费用限制进度条 -->
<LimitProgressBar
v-if="key.weeklyGPT5HighCostLimit > 0"
:current="key.weeklyGPT5HighCost || 0"
label="GPT-5H"
:limit="key.weeklyGPT5HighCostLimit"
type="gpt5-high"
/>
<!-- 时间窗口限制进度条 -->
<WindowLimitBar
v-if="key.rateLimitWindow > 0"
@@ -601,17 +592,16 @@
v-if="
!key.dailyCostLimit &&
!key.weeklyOpusCostLimit &&
!key.weeklyGPT5HighCostLimit &&
!key.rateLimitWindow
"
class="text-center"
class="dark:to-gray-750 relative h-7 w-full overflow-hidden rounded-md border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 dark:border-gray-700 dark:from-gray-800"
>
<span
class="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-1 text-xs text-gray-600 dark:bg-gray-600 dark:text-gray-300"
>
<i class="fas fa-infinity" />
<span>无限制</span>
</span>
<div class="flex h-full items-center justify-center gap-1.5">
<i class="fas fa-infinity text-xs text-gray-400 dark:text-gray-500" />
<span class="text-xs font-medium text-gray-400 dark:text-gray-500">
无限制
</span>
</div>
</div>
</div>
</td>
@@ -677,7 +667,7 @@
<span v-else-if="key.expiresAt">
<span
v-if="isApiKeyExpired(key.expiresAt)"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline dark:text-red-400"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditExpiry(key)"
>
@@ -686,7 +676,7 @@
</span>
<span
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline dark:text-orange-400"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditExpiry(key)"
>
@@ -717,7 +707,7 @@
<td class="whitespace-nowrap px-3 py-3" style="font-size: 13px">
<div class="flex gap-1">
<button
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:text-purple-400 dark:hover:bg-purple-900/20"
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
title="查看详细统计"
@click="showUsageDetails(key)"
>
@@ -726,7 +716,7 @@
</button>
<button
v-if="key && key.id"
class="rounded px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-900 dark:text-indigo-400 dark:hover:bg-indigo-900/20"
class="rounded px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-900 dark:hover:bg-indigo-900/20"
title="模型使用分布"
@click="toggleApiKeyModelStats(key.id)"
>
@@ -739,7 +729,7 @@
<span class="ml-1 hidden xl:inline">模型</span>
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-900 dark:text-blue-400 dark:hover:bg-blue-900/20"
class="rounded px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-900 dark:hover:bg-blue-900/20"
title="编辑"
@click="openEditApiKeyModal(key)"
>
@@ -752,7 +742,7 @@
(isApiKeyExpired(key.expiresAt) ||
isApiKeyExpiringSoon(key.expiresAt))
"
class="rounded px-2 py-1 text-xs font-medium text-green-600 transition-colors hover:bg-green-50 hover:text-green-900 dark:text-green-400 dark:hover:bg-green-900/20"
class="rounded px-2 py-1 text-xs font-medium text-green-600 transition-colors hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20"
title="续期"
@click="openRenewApiKeyModal(key)"
>
@@ -762,8 +752,8 @@
<button
:class="[
key.isActive
? 'text-orange-600 hover:bg-orange-50 hover:text-orange-900 dark:text-orange-400 dark:hover:bg-orange-900/20'
: 'text-green-600 hover:bg-green-50 hover:text-green-900 dark:text-green-400 dark:hover:bg-green-900/20',
? 'text-orange-600 hover:bg-orange-50 hover:text-orange-900 dark:hover:bg-orange-900/20'
: 'text-green-600 hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20',
'rounded px-2 py-1 text-xs font-medium transition-colors'
]"
:title="key.isActive ? '禁用' : '激活'"
@@ -775,7 +765,7 @@
}}</span>
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900 dark:text-red-400 dark:hover:bg-red-900/20"
class="rounded px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900 dark:hover:bg-red-900/20"
title="删除"
@click="deleteApiKey(key.id)"
>
@@ -920,7 +910,7 @@
<i class="fas fa-dollar-sign mr-1 text-xs text-green-500" />
费用:
</span>
<span class="font-semibold text-green-600 dark:text-green-400">{{
<span class="font-semibold text-green-600">{{
calculateModelCost(stat)
}}</span>
</div>
@@ -951,7 +941,7 @@
</div>
<div
v-if="stat.cacheCreateTokens > 0"
class="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400"
class="flex items-center justify-between text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-save mr-1" />
@@ -963,7 +953,7 @@
</div>
<div
v-if="stat.cacheReadTokens > 0"
class="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400"
class="flex items-center justify-between text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-download mr-1" />
@@ -992,9 +982,7 @@
/>
</div>
<div class="mt-1 text-right">
<span
class="text-xs font-medium text-indigo-600 dark:text-indigo-400"
>
<span class="text-xs font-medium text-indigo-600">
{{
calculateApiKeyModelPercentage(
stat.allTokens,
@@ -1164,10 +1152,7 @@
使用共享池
</div>
<!-- 显示所有者信息 -->
<div
v-if="isLdapEnabled && key.ownerDisplayName"
class="text-xs text-red-600 dark:text-red-400"
>
<div v-if="isLdapEnabled && key.ownerDisplayName" class="text-xs text-red-600">
<i class="fas fa-user mr-1" />
{{ key.ownerDisplayName }}
</div>
@@ -1182,7 +1167,7 @@
globalDateFilter.type === 'custom' ? '累计统计' : '今日使用'
}}</span>
<button
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
class="text-xs text-blue-600 hover:text-blue-800"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line mr-1" />详情
@@ -1196,7 +1181,7 @@
<p class="text-xs text-gray-500 dark:text-gray-400">请求</p>
</div>
<div>
<p class="text-sm font-semibold text-green-600 dark:text-green-400">
<p class="text-sm font-semibold text-green-600">
${{ (key.dailyCost || 0).toFixed(2) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
@@ -1269,9 +1254,7 @@
<div class="flex items-center gap-1">
<span
:class="
isApiKeyExpiringSoon(key.expiresAt)
? 'font-semibold text-orange-600 dark:text-orange-400'
: ''
isApiKeyExpiringSoon(key.expiresAt) ? 'font-semibold text-orange-600' : ''
"
>
{{ key.expiresAt ? formatDate(key.expiresAt) : '永不过期' }}
@@ -1308,7 +1291,7 @@
<!-- 操作按钮 -->
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3 dark:border-gray-600">
<button
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
@@ -1326,7 +1309,7 @@
key.expiresAt &&
(isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))
"
class="flex-1 rounded-lg bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
class="flex-1 rounded-lg bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1" />
@@ -1335,8 +1318,8 @@
<button
:class="[
key.isActive
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50'
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50',
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50'
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:hover:bg-green-900/50',
'rounded-lg px-3 py-1.5 text-xs transition-colors'
]"
@click="toggleApiKeyStatus(key)"
@@ -1345,7 +1328,7 @@
{{ key.isActive ? '禁用' : '激活' }}
</button>
<button
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash" />
@@ -1605,17 +1588,11 @@
<!-- 创建者 -->
<td v-if="isLdapEnabled" class="px-3 py-3">
<div class="text-xs">
<span
v-if="key.createdBy === 'admin'"
class="text-blue-600 dark:text-blue-400"
>
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1 text-xs" />
管理员
</span>
<span
v-else-if="key.userUsername"
class="text-green-600 dark:text-green-400"
>
<span v-else-if="key.userUsername" class="text-green-600">
<i class="fas fa-user mr-1 text-xs" />
{{ key.userUsername }}
</span>
@@ -1635,17 +1612,11 @@
<!-- 删除者 -->
<td class="px-3 py-3">
<div class="text-xs">
<span
v-if="key.deletedByType === 'admin'"
class="text-blue-600 dark:text-blue-400"
>
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1 text-xs" />
{{ key.deletedBy }}
</span>
<span
v-else-if="key.deletedByType === 'user'"
class="text-green-600 dark:text-green-400"
>
<span v-else-if="key.deletedByType === 'user'" class="text-green-600">
<i class="fas fa-user mr-1 text-xs" />
{{ key.deletedBy }}
</span>
@@ -3340,7 +3311,6 @@ const formatDate = (dateString) => {
// if (progress >= 100) return 'bg-red-500'
// if (progress >= 80) return 'bg-yellow-500'
// return 'bg-green-500'
// }
// 获取 Opus 周费用进度 - 已移到 LimitBadge 组件中

View File

@@ -0,0 +1,604 @@
<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 使用统计' : '使用教程'"
: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 === '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 === '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 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>
</div>
</div>
</div>
<!-- 基本信息和统计概览 -->
<StatsOverview />
<!-- Token 分布和限制配置 -->
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<TokenDistribution />
<!-- 单key模式下显示限制配置 -->
<LimitConfig v-if="!multiKeyMode" />
<!-- 多key模式下显示聚合统计卡片填充右侧空白 -->
<AggregatedStatsCard v-if="multiKeyMode" />
</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, 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 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'
const route = useRoute()
const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore()
// 当前标签页
const currentTab = ref('stats')
// 主题相关
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 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')
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
// 加载 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-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);
}
/* 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>